Browse Source

Continues with refactoring.

pull/282/head
Sebastian Stehle 8 years ago
parent
commit
5e17bc3db2
  1. 5
      src/Squidex/app/features/administration/state/users.state.ts
  2. 10
      src/Squidex/app/features/content/module.ts
  3. 28
      src/Squidex/app/features/content/pages/content/content-field.component.html
  4. 24
      src/Squidex/app/features/content/pages/content/content-field.component.ts
  5. 31
      src/Squidex/app/features/content/pages/content/content-history.component.ts
  6. 31
      src/Squidex/app/features/content/pages/content/content-page.component.html
  7. 226
      src/Squidex/app/features/content/pages/content/content-page.component.ts
  8. 46
      src/Squidex/app/features/content/pages/contents/contents-page.component.html
  9. 278
      src/Squidex/app/features/content/pages/contents/contents-page.component.ts
  10. 2
      src/Squidex/app/features/content/pages/contents/search-form.component.ts
  11. 4
      src/Squidex/app/features/content/pages/messages.ts
  12. 10
      src/Squidex/app/features/content/shared/assets-editor.component.ts
  13. 22
      src/Squidex/app/features/content/shared/content-item.component.html
  14. 84
      src/Squidex/app/features/content/shared/content-item.component.ts
  15. 18
      src/Squidex/app/features/content/shared/contents-selector.component.html
  16. 71
      src/Squidex/app/features/content/shared/contents-selector.component.ts
  17. 20
      src/Squidex/app/features/content/shared/references-editor.component.html
  18. 26
      src/Squidex/app/features/content/shared/references-editor.component.scss
  19. 17
      src/Squidex/app/features/content/shared/references-editor.component.ts
  20. 2
      src/Squidex/app/features/rules/pages/events/rule-events-page.component.html
  21. 24
      src/Squidex/app/features/rules/pages/events/rule-events-page.component.ts
  22. 18
      src/Squidex/app/framework/utils/types.ts
  23. 69
      src/Squidex/app/shared/components/app-context.ts
  24. 12
      src/Squidex/app/shared/components/assets-list.component.html
  25. 23
      src/Squidex/app/shared/components/assets-list.component.ts
  26. 4
      src/Squidex/app/shared/components/assets-selector.component.html
  27. 2
      src/Squidex/app/shared/components/assets-selector.component.ts
  28. 25
      src/Squidex/app/shared/components/history.component.ts
  29. 4
      src/Squidex/app/shared/components/language-selector.component.ts
  30. 1
      src/Squidex/app/shared/declarations.ts
  31. 62
      src/Squidex/app/shared/guards/content-must-exist.guard.spec.ts
  32. 38
      src/Squidex/app/shared/guards/content-must-exist.guard.ts
  33. 46
      src/Squidex/app/shared/guards/resolve-content.guard.ts
  34. 37
      src/Squidex/app/shared/guards/unset-content.guard.spec.ts
  35. 24
      src/Squidex/app/shared/guards/unset-content.guard.ts
  36. 4
      src/Squidex/app/shared/internal.ts
  37. 8
      src/Squidex/app/shared/module.ts
  38. 52
      src/Squidex/app/shared/services/contents.service.spec.ts
  39. 54
      src/Squidex/app/shared/services/contents.service.ts
  40. 13
      src/Squidex/app/shared/services/schemas.service.ts
  41. 382
      src/Squidex/app/shared/state/contents.state.ts

5
src/Squidex/app/features/administration/state/users.state.ts

@ -134,7 +134,10 @@ export class UsersState extends State<Snapshot> {
} }
public load(notifyLoad = false): Observable<any> { public load(notifyLoad = false): Observable<any> {
return this.usersService.getUsers(this.snapshot.usersPager.pageSize, this.snapshot.usersPager.skip, this.snapshot.usersQuery) return this.usersService.getUsers(
this.snapshot.usersPager.pageSize,
this.snapshot.usersPager.skip,
this.snapshot.usersQuery)
.do(dtos => { .do(dtos => {
if (notifyLoad) { if (notifyLoad) {
this.dialogs.notifyInfo('Users reloaded.'); this.dialogs.notifyInfo('Users reloaded.');

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

@ -11,11 +11,12 @@ import { DndModule } from 'ng2-dnd';
import { import {
CanDeactivateGuard, CanDeactivateGuard,
ContentMustExistGuard,
LoadLanguagesGuard, LoadLanguagesGuard,
ResolveContentGuard,
SchemaMustExistPublishedGuard, SchemaMustExistPublishedGuard,
SqxFrameworkModule, SqxFrameworkModule,
SqxSharedModule SqxSharedModule,
UnsetContentGuard
} from '@app/shared'; } from '@app/shared';
import { import {
@ -52,15 +53,14 @@ const routes: Routes = [
{ {
path: 'new', path: 'new',
component: ContentPageComponent, component: ContentPageComponent,
canActivate: [UnsetContentGuard],
canDeactivate: [CanDeactivateGuard] canDeactivate: [CanDeactivateGuard]
}, },
{ {
path: ':contentId', path: ':contentId',
component: ContentPageComponent, component: ContentPageComponent,
canActivate: [ContentMustExistGuard],
canDeactivate: [CanDeactivateGuard], canDeactivate: [CanDeactivateGuard],
resolve: {
content: ResolveContentGuard
},
children: [ children: [
{ {
path: 'history', path: 'history',

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

@ -7,7 +7,11 @@
<ng-container *ngIf="field.isLocalizable && languages.length > 1"> <ng-container *ngIf="field.isLocalizable && languages.length > 1">
<div class="languages-buttons" #buttonLanguages> <div class="languages-buttons" #buttonLanguages>
<sqx-language-selector size="sm" (selectedLanguageChanged)="selectLanguage($event)" [languages]="languages.values"></sqx-language-selector> <sqx-language-selector size="sm"
[selectedLanguage]="language"
(selectedLanguageChange)="languageChange.emit($event)"
[languages]="languages.values">
</sqx-language-selector>
</div> </div>
<sqx-onboarding-tooltip id="languages" [for]="buttonLanguages" position="topRight" after="120000"> <sqx-onboarding-tooltip id="languages" [for]="buttonLanguages" position="topRight" after="120000">
@ -19,21 +23,21 @@
<div [ngSwitch]="field.properties.fieldType"> <div [ngSwitch]="field.properties.fieldType">
<div *ngSwitchCase="'Number'"> <div *ngSwitchCase="'Number'">
<div [ngSwitch]="field.properties.editor"> <div [ngSwitch]="field.properties['editor']">
<div *ngSwitchCase="'Input'"> <div *ngSwitchCase="'Input'">
<input class="form-control" type="number" [formControl]="selectedFormControl" [placeholder]="field.displayPlaceholder" /> <input class="form-control" type="number" [formControl]="selectedFormControl" [placeholder]="field.displayPlaceholder" />
</div> </div>
<div *ngSwitchCase="'Stars'"> <div *ngSwitchCase="'Stars'">
<sqx-stars [formControl]="selectedFormControl" [maximumStars]="field.properties.maxValue"></sqx-stars> <sqx-stars [formControl]="selectedFormControl" [maximumStars]="field.properties['maxValue']"></sqx-stars>
</div> </div>
<div *ngSwitchCase="'Dropdown'"> <div *ngSwitchCase="'Dropdown'">
<select class="form-control" [formControl]="selectedFormControl"> <select class="form-control" [formControl]="selectedFormControl">
<option [ngValue]="null"></option> <option [ngValue]="null"></option>
<option *ngFor="let value of field.properties.allowedValues" [ngValue]="value">{{value}}</option> <option *ngFor="let value of field.properties['allowedValues']" [ngValue]="value">{{value}}</option>
</select> </select>
</div> </div>
<div *ngSwitchCase="'Radio'"> <div *ngSwitchCase="'Radio'">
<div class="form-check form-check-inline" *ngFor="let value of field.properties.allowedValues"> <div class="form-check form-check-inline" *ngFor="let value of field.properties['allowedValues']">
<input class="form-check-input" type="radio" [value]="value" [formControl]="selectedFormControl" /> <input class="form-check-input" type="radio" [value]="value" [formControl]="selectedFormControl" />
<label class="form-check-label"> <label class="form-check-label">
{{value}} {{value}}
@ -43,7 +47,7 @@
</div> </div>
</div> </div>
<div *ngSwitchCase="'String'"> <div *ngSwitchCase="'String'">
<div [ngSwitch]="field.properties.editor"> <div [ngSwitch]="field.properties['editor']">
<div *ngSwitchCase="'Input'"> <div *ngSwitchCase="'Input'">
<input class="form-control" type="text" [formControl]="selectedFormControl" [placeholder]="field.displayPlaceholder" /> <input class="form-control" type="text" [formControl]="selectedFormControl" [placeholder]="field.displayPlaceholder" />
</div> </div>
@ -62,11 +66,11 @@
<div *ngSwitchCase="'Dropdown'"> <div *ngSwitchCase="'Dropdown'">
<select class="form-control" [formControl]="selectedFormControl"> <select class="form-control" [formControl]="selectedFormControl">
<option [ngValue]="null"></option> <option [ngValue]="null"></option>
<option *ngFor="let value of field.properties.allowedValues" [ngValue]="value">{{value}}</option> <option *ngFor="let value of field.properties['allowedValues']" [ngValue]="value">{{value}}</option>
</select> </select>
</div> </div>
<div *ngSwitchCase="'Radio'"> <div *ngSwitchCase="'Radio'">
<div class="form-check form-check-inline" *ngFor="let value of field.properties.allowedValues"> <div class="form-check form-check-inline" *ngFor="let value of field.properties['allowedValues']">
<input class="form-check-input" type="radio" value="{{value}}" [formControl]="selectedFormControl" /> <input class="form-check-input" type="radio" value="{{value}}" [formControl]="selectedFormControl" />
<label class="form-check-label"> <label class="form-check-label">
{{value}} {{value}}
@ -76,7 +80,7 @@
</div> </div>
</div> </div>
<div *ngSwitchCase="'Boolean'"> <div *ngSwitchCase="'Boolean'">
<div [ngSwitch]="field.properties.editor"> <div [ngSwitch]="field.properties['editor']">
<div *ngSwitchCase="'Toggle'"> <div *ngSwitchCase="'Toggle'">
<sqx-toggle [formControl]="selectedFormControl"></sqx-toggle> <sqx-toggle [formControl]="selectedFormControl"></sqx-toggle>
</div> </div>
@ -88,7 +92,7 @@
</div> </div>
</div> </div>
<div *ngSwitchCase="'DateTime'"> <div *ngSwitchCase="'DateTime'">
<sqx-date-time-editor enforceTime="true" [mode]="field.properties.editor" [formControl]="selectedFormControl"></sqx-date-time-editor> <sqx-date-time-editor enforceTime="true" [mode]="field.properties['editor']" [formControl]="selectedFormControl"></sqx-date-time-editor>
</div> </div>
<div *ngSwitchCase="'Geolocation'"> <div *ngSwitchCase="'Geolocation'">
<sqx-geolocation-editor [formControl]="selectedFormControl"></sqx-geolocation-editor> <sqx-geolocation-editor [formControl]="selectedFormControl"></sqx-geolocation-editor>
@ -107,12 +111,12 @@
[formControl]="selectedFormControl" [formControl]="selectedFormControl"
[language]="language" [language]="language"
[languages]="languages" [languages]="languages"
[schemaId]="field.properties.schemaId"> [schemaId]="field.properties['schemaId']">
</sqx-references-editor> </sqx-references-editor>
</div> </div>
</div> </div>
<small class="form-text text-muted" *ngIf="field.properties.hints && field.properties.hints.length > 0"> <small class="form-text text-muted" *ngIf="field.properties['hints'] && field.properties['hints'].length > 0">
{{field.properties.hints}} {{field.properties.hints}}
</small> </small>
</div> </div>

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

@ -5,7 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { Component, Input, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
import { AbstractControl, FormGroup } from '@angular/forms'; import { AbstractControl, FormGroup } from '@angular/forms';
import { import {
@ -18,9 +18,10 @@ import {
@Component({ @Component({
selector: 'sqx-content-field', selector: 'sqx-content-field',
styleUrls: ['./content-field.component.scss'], styleUrls: ['./content-field.component.scss'],
templateUrl: './content-field.component.html' templateUrl: './content-field.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class ContentFieldComponent implements OnInit { export class ContentFieldComponent implements OnChanges {
@Input() @Input()
public field: FieldDto; public field: FieldDto;
@ -30,6 +31,9 @@ export class ContentFieldComponent implements OnInit {
@Input() @Input()
public language: AppLanguageDto; public language: AppLanguageDto;
@Output()
public languageChange = new EventEmitter<AppLanguageDto>();
@Input() @Input()
public languages: ImmutableArray<AppLanguageDto>; public languages: ImmutableArray<AppLanguageDto>;
@ -38,23 +42,13 @@ export class ContentFieldComponent implements OnInit {
public selectedFormControl: AbstractControl; public selectedFormControl: AbstractControl;
public ngOnInit() { public ngOnChanges() {
if (!this.language) {
this.language = this.languages.at(0);
}
if (this.field.isLocalizable) { if (this.field.isLocalizable) {
this.selectedFormControl = this.fieldForm.controls[this.language.iso2Code]; this.selectedFormControl = this.fieldForm.controls[this.language.iso2Code];
this.selectedFormControl['_clearChangeFns']();
} else { } else {
this.selectedFormControl = this.fieldForm.controls[fieldInvariant]; this.selectedFormControl = this.fieldForm.controls[fieldInvariant];
} }
} }
public selectLanguage(language: AppLanguageDto) {
this.selectedFormControl['_clearChangeFns']();
this.selectedFormControl = this.fieldForm.controls[language.iso2Code];
this.language = language;
}
} }

31
src/Squidex/app/features/content/pages/content/content-history.component.ts

@ -6,16 +6,19 @@
*/ */
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { import {
allParams, allParams,
AppContext, AppsState,
formatHistoryMessage, formatHistoryMessage,
HistoryChannelUpdated, HistoryChannelUpdated,
HistoryEventDto, HistoryEventDto,
HistoryService, HistoryService,
UsersProviderService MessageBus,
UsersProviderService,
Version
} from '@app/shared'; } from '@app/shared';
import { ContentVersionSelected } from './../messages'; import { ContentVersionSelected } from './../messages';
@ -23,17 +26,14 @@ import { ContentVersionSelected } from './../messages';
@Component({ @Component({
selector: 'sqx-history', selector: 'sqx-history',
styleUrls: ['./content-history.component.scss'], styleUrls: ['./content-history.component.scss'],
templateUrl: './content-history.component.html', templateUrl: './content-history.component.html'
providers: [
AppContext
]
}) })
export class ContentHistoryComponent { export class ContentHistoryComponent {
public get channel(): string { public get channel(): string {
let channelPath = this.ctx.route.snapshot.data['channel']; let channelPath = this.route.snapshot.data['channel'];
if (channelPath) { if (channelPath) {
const params = allParams(this.ctx.route); const params = allParams(this.route);
for (let key in params) { for (let key in params) {
if (params.hasOwnProperty(key)) { if (params.hasOwnProperty(key)) {
@ -48,17 +48,20 @@ export class ContentHistoryComponent {
} }
public events: Observable<HistoryEventDto[]> = public events: Observable<HistoryEventDto[]> =
Observable.timer(0, 10000).merge(this.ctx.bus.of(HistoryChannelUpdated).delay(1000)) Observable.timer(0, 10000).merge(this.messageBus.of(HistoryChannelUpdated).delay(1000))
.switchMap(app => this.historyService.getHistory(this.ctx.appName, this.channel)); .switchMap(app => this.historyService.getHistory(this.appsState.appName, this.channel));
constructor(public readonly ctx: AppContext, constructor(
private readonly users: UsersProviderService, private readonly appsState: AppsState,
private readonly historyService: HistoryService private readonly historyService: HistoryService,
private readonly messageBus: MessageBus,
private readonly route: ActivatedRoute,
private readonly users: UsersProviderService
) { ) {
} }
public loadVersion(version: number) { public loadVersion(version: number) {
this.ctx.bus.emit(new ContentVersionSelected(version)); this.messageBus.emit(new ContentVersionSelected(new Version(version.toString())));
} }
public format(message: string): Observable<string> { public format(message: string): Observable<string> {

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

@ -1,25 +1,25 @@
<sqx-title message="{app} | {schema} | Content" parameter1="app" parameter2="schema" [value1]="ctx.appName" [value2]="schema?.displayName"></sqx-title> <sqx-title message="{app} | {schema} | Content" parameter1="app" parameter2="schema" [value1]="appsState.appName" [value2]="schema.displayName"></sqx-title>
<form [formGroup]="contentForm" (ngSubmit)="saveAndPublish()"> <form [formGroup]="contentForm.form" (ngSubmit)="saveAndPublish()">
<sqx-panel desiredWidth="*" showSidebar="true"> <sqx-panel desiredWidth="*" [showSidebar]="content">
<ng-container title> <ng-container title>
<a class="btn btn-link" (click)="back()"> <a class="btn btn-link" (click)="back()">
<i class="icon-angle-left"></i> <i class="icon-angle-left"></i>
</a> </a>
<ng-container *ngIf="isNewMode"> <ng-container *ngIf="!content">
New Content New Content
</ng-container> </ng-container>
<ng-container *ngIf="!isNewMode && content.status !== 'Archived'"> <ng-container *ngIf="content && content.status !== 'Archived'">
Edit Content Edit Content
</ng-container> </ng-container>
<ng-container *ngIf="!isNewMode && content.status === 'Archived'"> <ng-container *ngIf="content && content.status === 'Archived'">
Show Content Show Content
</ng-container> </ng-container>
</ng-container> </ng-container>
<ng-container menu> <ng-container menu>
<ng-container *ngIf="isNewMode; else notNew"> <ng-container *ngIf="!content; else notNew">
<button type="button" class="btn btn-secondary" (click)="saveAsDraft()" title="CTRL + S"> <button type="button" class="btn btn-secondary" (click)="saveAsDraft()" title="CTRL + S">
Save as Draft Save as Draft
</button> </button>
@ -38,24 +38,31 @@
</ng-container> </ng-container>
<ng-container content> <ng-container content>
<div class="panel-alert panel-alert-danger" *ngIf="contentOld"> <div class="panel-alert panel-alert-danger" *ngIf="contentVersion">
<div class="float-right"> <div class="float-right">
<a class="force" (click)="showLatest()">View latest</a> <a class="force" (click)="showLatest()">View latest</a>
</div> </div>
Viewing <strong>{{content.lastModifiedBy | sqxUserNameRef:null}}'s</strong> changes of {{content.lastModified | sqxShortDate}}.
Viewing <strong>version {{contentVersion.value</strong>.
</div> </div>
<div *ngFor="let field of schema.fields"> <div *ngFor="let field of schema.fields">
<sqx-content-field [field]="field" [fieldForm]="contentForm.controls[field.name]" [languages]="languages" [contentFormSubmitted]="contentFormSubmitted"></sqx-content-field> <sqx-content-field
[field]="field"
[fieldForm]="contentForm.form.controls[field.name]"
[(language)]="language"
[languages]="languages"
[contentFormSubmitted]="contentForm.submitted | async">
</sqx-content-field>
</div> </div>
</ng-container> </ng-container>
<ng-container sidebar> <ng-container sidebar>
<a class="panel-link" routerLink="history" routerLinkActive="active" #linkHistory *ngIf="!isNewMode"> <a class="panel-link" routerLink="history" routerLinkActive="active" #linkHistory>
<i class="icon-time"></i> <i class="icon-time"></i>
</a> </a>
<sqx-onboarding-tooltip id="history" [for]="linkHistory" position="leftTop" after="120000" *ngIf="!isNewMode"> <sqx-onboarding-tooltip id="history" [for]="linkHistory" position="leftTop" after="120000">
The sidebar navigation contains useful context specific links. Here you can view the history how this schema has changed over time. The sidebar navigation contains useful context specific links. Here you can view the history how this schema has changed over time.
</sqx-onboarding-tooltip> </sqx-onboarding-tooltip>
</ng-container> </ng-container>

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

@ -6,21 +6,22 @@
*/ */
import { Component, OnDestroy, OnInit } from '@angular/core'; import { Component, OnDestroy, OnInit } from '@angular/core';
import { AbstractControl, FormControl, FormGroup } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router';
import { Router } from '@angular/router';
import { Observable, Subscription } from 'rxjs'; import { Observable, Subscription } from 'rxjs';
import { ContentVersionSelected } from './../messages'; import { ContentVersionSelected } from './../messages';
import { import {
AppContext,
AppLanguageDto, AppLanguageDto,
AppsState,
CanComponentDeactivate, CanComponentDeactivate,
ContentDto, ContentDto,
ContentsService, ContentsState,
fieldInvariant, DialogService,
EditContentForm,
ImmutableArray, ImmutableArray,
LanguagesState, LanguagesState,
MessageBus,
SchemaDetailsDto, SchemaDetailsDto,
SchemasState, SchemasState,
Version Version
@ -29,81 +30,78 @@ import {
@Component({ @Component({
selector: 'sqx-content-page', selector: 'sqx-content-page',
styleUrls: ['./content-page.component.scss'], styleUrls: ['./content-page.component.scss'],
templateUrl: './content-page.component.html', templateUrl: './content-page.component.html'
providers: [
AppContext
]
}) })
export class ContentPageComponent implements CanComponentDeactivate, OnDestroy, OnInit { export class ContentPageComponent implements CanComponentDeactivate, OnDestroy, OnInit {
private contentVersionSelectedSubscription: Subscription;
private languagesSubscription: Subscription; private languagesSubscription: Subscription;
private contentSubscription: Subscription;
private contentVersionSelectedSubscription: Subscription;
private selectedSchemaSubscription: Subscription; private selectedSchemaSubscription: Subscription;
public schema: SchemaDetailsDto; public schema: SchemaDetailsDto;
public content: ContentDto; public content: ContentDto;
public contentOld: ContentDto | null; public contentVersion: Version | null;
public contentFormSubmitted = false; public contentForm: EditContentForm;
public contentForm: FormGroup;
public isNewMode = true;
public language: AppLanguageDto;
public languages: ImmutableArray<AppLanguageDto>; public languages: ImmutableArray<AppLanguageDto>;
constructor( constructor(
public readonly ctx: AppContext, public readonly appsState: AppsState,
public readonly languagesState: LanguagesState, private readonly contentsState: ContentsState,
private readonly contentsService: ContentsService, private readonly dialogs: DialogService,
private readonly languagesState: LanguagesState,
private readonly messageBus: MessageBus,
private readonly route: ActivatedRoute,
private readonly router: Router, private readonly router: Router,
private readonly schemasState: SchemasState private readonly schemasState: SchemasState
) { ) {
} }
public ngOnDestroy() { public ngOnDestroy() {
this.contentVersionSelectedSubscription.unsubscribe();
this.languagesSubscription.unsubscribe(); this.languagesSubscription.unsubscribe();
this.contentSubscription.unsubscribe();
this.contentVersionSelectedSubscription.unsubscribe();
this.selectedSchemaSubscription.unsubscribe(); this.selectedSchemaSubscription.unsubscribe();
} }
public ngOnInit() { public ngOnInit() {
this.contentVersionSelectedSubscription =
this.ctx.bus.of(ContentVersionSelected)
.subscribe(message => {
this.loadVersion(message.version);
});
this.languagesSubscription = this.languagesSubscription =
this.languagesState.languages this.languagesState.languages
.subscribe(languages => { .subscribe(languages => {
this.languages = languages.map(x => x.language); this.languages = languages.map(x => x.language);
this.language = this.languages.at(0);
}); });
this.selectedSchemaSubscription = this.selectedSchemaSubscription =
this.schemasState.selectedSchema this.schemasState.selectedSchema.filter(s => !!s)
.subscribe(schema => { .subscribe(schema => {
this.setupContentForm(schema!); this.schema = schema!;
this.contentForm = new EditContentForm(this.schema, this.languages);
}); });
this.ctx.route.data.map(d => d.content) this.contentSubscription =
.subscribe((content: ContentDto) => { this.contentsState.selectedContent.filter(c => !!c)
this.reloadContentForm(content); .subscribe(content => {
this.content = content!;
this.loadContent(content!.data);
});
this.contentVersionSelectedSubscription =
this.messageBus.of(ContentVersionSelected)
.subscribe(message => {
this.loadVersion(message.version);
}); });
} }
public canDeactivate(): Observable<boolean> { public canDeactivate(): Observable<boolean> {
if (!this.contentForm.dirty || this.isNewMode) { if (!this.contentForm.form.dirty || !this.content) {
return Observable.of(true); return Observable.of(true);
} else { } else {
return this.ctx.confirmUnsavedChanges(); return this.dialogs.confirmUnsavedChanges();
}
}
public showLatest() {
if (this.contentOld) {
this.content = this.contentOld;
this.contentOld = null;
this.reloadContentForm(this.content);
} }
} }
@ -116,153 +114,59 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy,
} }
private saveContent(publish: boolean) { private saveContent(publish: boolean) {
this.contentFormSubmitted = true; const value = this.contentForm.submit();
if (this.contentForm.valid) {
this.disableContentForm();
const requestDto = this.contentForm.value;
if (this.isNewMode) { if (value) {
this.contentsService.postContent(this.ctx.appName, this.schema.name, requestDto, publish) if (!this.content) {
this.contentsState.create(value, publish)
.subscribe(dto => { .subscribe(dto => {
this.content = dto;
this.ctx.notifyInfo('Content created successfully.');
this.back(); this.back();
}, error => { }, error => {
this.ctx.notifyError(error); this.contentForm.submitFailed(error);
this.enableContentForm();
}); });
} else { } else {
this.contentsService.putContent(this.ctx.appName, this.schema.name, this.content.id, requestDto, this.content.version) this.contentsState.create(value, publish)
.subscribe(dto => { .subscribe(dto => {
const content = this.content.update(dto.payload, this.ctx.userToken, dto.version); this.contentForm.submitCompleted();
this.ctx.notifyInfo('Content saved successfully.');
this.enableContentForm();
this.reloadContentForm(content);
}, error => { }, error => {
this.ctx.notifyError(error); this.contentForm.submitFailed(error);
this.enableContentForm();
}); });
} }
} else { } else {
this.ctx.notifyError('Content element not valid, please check the field with the red bar on the left in all languages (if localizable).'); this.dialogs.notifyError('Content element not valid, please check the field with the red bar on the left in all languages (if localizable).');
}
}
private loadVersion(version: number) {
if (!this.isNewMode && this.content) {
this.contentsService.getVersionData(this.ctx.appName, this.schema.name, this.content.id, new Version(version.toString()))
.subscribe(dto => {
if (this.content.version.value !== version.toString()) {
this.contentOld = this.content;
} else {
this.contentOld = null;
}
this.ctx.notifyInfo('Content version loaded successfully.');
this.reloadContentForm(this.content.setData(dto));
}, error => {
this.ctx.notifyError(error);
});
} }
} }
public back() { public back() {
this.router.navigate([this.schema.name], { relativeTo: this.ctx.route.parent!.parent, replaceUrl: true }); this.router.navigate([this.schema.name], { relativeTo: this.route.parent!.parent, replaceUrl: true });
} }
private disableContentForm() { private loadContent(data: any) {
this.contentForm.disable(); this.contentForm.load(data);
} }
private enableContentForm() { private loadVersion(version: Version) {
this.contentForm.markAsPristine(); if (this.content) {
this.contentsState.loadVersion(this.content, version)
if (this.schema.fields.length === 0) { .subscribe(dto => {
this.contentForm.enable(); if (this.content.version.value !== version.toString()) {
} else { this.contentVersion = version;
for (const field of this.schema.fields) {
const fieldForm = <FormGroup>this.contentForm.controls[field.name];
if (field.isDisabled) {
fieldForm.disable();
} else { } else {
fieldForm.enable(); this.contentVersion = null;
}
} }
}
}
private setupContentForm(schema: SchemaDetailsDto) {
this.schema = schema;
const controls: { [key: string]: AbstractControl } = {}; this.dialogs.notifyInfo('Content version loaded successfully.');
for (const field of schema.fields) { this.loadContent(dto);
const fieldForm = new FormGroup({}); });
if (field.isLocalizable) {
for (let language of this.languages.values) {
fieldForm.setControl(language.iso2Code, new FormControl(undefined, field.createValidators(language.isOptional)));
}
} else {
fieldForm.setControl(fieldInvariant, new FormControl(undefined, field.createValidators(false)));
}
controls[field.name] = fieldForm;
}
this.contentForm = new FormGroup(controls);
this.enableContentForm();
}
private reloadContentForm(content: ContentDto) {
this.content = content;
this.contentForm.markAsPristine();
this.isNewMode = !this.content;
if (!this.isNewMode) {
for (const field of this.schema.fields) {
const fieldValue = this.content.data[field.name] || {};
const fieldForm = <FormGroup>this.contentForm.controls[field.name];
if (field.isLocalizable) {
for (let language of this.languages.values) {
fieldForm.controls[language.iso2Code].setValue(fieldValue[language.iso2Code]);
}
} else {
fieldForm.controls[fieldInvariant].setValue(fieldValue[fieldInvariant] === undefined ? null : fieldValue[fieldInvariant]);
}
} }
if (this.content.status === 'Archived') {
this.contentForm.disable();
} }
} else {
for (const field of this.schema.fields) {
const defaultValue = field.defaultValue();
if (defaultValue) { public showLatest() {
const fieldForm = <FormGroup>this.contentForm.controls[field.name]; if (this.contentVersion) {
this.contentVersion = null;
if (field.isLocalizable) { this.loadContent(this.content.data);
for (let language of this.languages.values) {
fieldForm.controls[language.iso2Code].setValue(defaultValue);
}
} else {
fieldForm.controls[fieldInvariant].setValue(defaultValue);
}
}
}
} }
} }
} }

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

@ -1,8 +1,8 @@
<sqx-title message="{app} | {schema} | Contents" parameter1="app" parameter2="schema" [value1]="ctx.appName" [value2]="schema?.displayName"></sqx-title> <sqx-title message="{app} | {schema} | Contents" parameter1="app" parameter2="schema" [value1]="appsState.appName" [value2]="schema.displayName"></sqx-title>
<sqx-panel desiredWidth="*" contentClass="grid"> <sqx-panel desiredWidth="*" contentClass="grid">
<ng-container title> <ng-container title>
<ng-container *ngIf="isArchive; else noArchive"> <ng-container *ngIf="contentsState.isArchive | async; else noArchive">
Archive Archive
</ng-container> </ng-container>
@ -12,26 +12,34 @@
</ng-container> </ng-container>
<ng-container menu> <ng-container menu>
<button class="btn btn-link btn-secondary" (click)="load(true)" title="Refresh Contents (CTRL + SHIFT + R)"> <button class="btn btn-link btn-secondary" (click)="reload()" title="Refresh Contents (CTRL + SHIFT + R)">
<i class="icon-reset"></i> Refresh <i class="icon-reset"></i> Refresh
</button> </button>
<sqx-shortcut keys="ctrl+shift+r" (trigger)="load(true)"></sqx-shortcut> <sqx-shortcut keys="ctrl+shift+r" (trigger)="reload()"></sqx-shortcut>
<sqx-search-form (queryChanged)="search($event)" [query]="contentsQuery" enableShortcut="true"></sqx-search-form> <sqx-search-form
(queryChanged)="search($event)"
[query]="contentsState.contentsQuery | async"
(archivedChanged)="goArchive($event)"
[archived]="contentsState.isArchive | async"
enableShortcut="true">
</sqx-search-form>
<ng-container *ngIf="languages.length > 1"> <ng-container *ngIf="languages.length > 1">
<sqx-language-selector class="languages-buttons" (selectedLanguageChanged)="selectLanguage($event)" [languages]="languages.values"></sqx-language-selector> <sqx-language-selector class="languages-buttons" (selectedLanguageChange)="selectLanguage($event)" [languages]="languages.values"></sqx-language-selector>
</ng-container> </ng-container>
<button class="btn btn-success" #newButton routerLink="new" title="New Content (CTRL + SHIFT + G)"> <button class="btn btn-success" #newButton routerLink="new" title="New Content (CTRL + SHIFT + G)">
<i class="icon-plus"></i> New <i class="icon-plus"></i> New
</button> </button>
<sqx-shortcut keys="ctrl+shift+g" (trigger)="newButton.click()"></sqx-shortcut>
</ng-container> </ng-container>
<ng-container content> <ng-container content>
<div class="grid-header"> <div class="grid-header">
<table class="table table-items table-fixed" *ngIf="contentItems"> <table class="table table-items table-fixed">
<thead> <thead>
<tr> <tr>
<th class="cell-select" *ngIf="!isReadOnly"> <th class="cell-select" *ngIf="!isReadOnly">
@ -65,11 +73,11 @@
Unpublish Unpublish
</button> </button>
<button class="btn btn-secondary" (click)="archiveSelected()" *ngIf="!isArchive"> <button class="btn btn-secondary" (click)="archiveSelected()" *ngIf="(contentsState.isArchive | async) === false">
Archive Archive
</button> </button>
<button class="btn btn-secondary" (click)="restoreSelected()" *ngIf="isArchive"> <button class="btn btn-secondary" (click)="restoreSelected()" *ngIf="contentsState.isArchive | async">
Restore Restore
</button> </button>
@ -83,20 +91,20 @@
<div class="grid-content"> <div class="grid-content">
<div sqxIgnoreScrollbar> <div sqxIgnoreScrollbar>
<table class="table table-items table-fixed" *ngIf="contentItems"> <table class="table table-items table-fixed">
<tbody> <tbody>
<ng-template ngFor let-content [ngForOf]="contentItems" [ngForTrackBy]="trackByContent"> <ng-template ngFor let-content [ngForOf]="contentsState.contents | async" [ngForTrackBy]="trackByContent">
<tr [sqxContent]="content" [routerLink]="[content.id]" routerLinkActive="active" <tr [sqxContent]="content" [routerLink]="[content.id]" routerLinkActive="active"
[language]="language" [language]="language"
[schema]="schema" [schema]="schema"
[selected]="isItemSelected(content)" [selected]="isItemSelected(content)"
(selectedChange)="selectItem(content, $event)" (selectedChange)="selectItem(content, $event)"
(unpublishing)="unpublishContent(content)" (unpublishing)="unpublish(content)"
(publishing)="publishContent(content)" (publishing)="publish(content)"
(archiving)="archiveContent(content)" (archiving)="archive(content)"
(restoring)="restoreContent(content)" (restoring)="restore(content)"
(deleting)="deleteContent(content)" (deleting)="delete(content)">
(saved)="onContentSaved(content, $event)"></tr> </tr>
<tr class="spacer"></tr> <tr class="spacer"></tr>
</ng-template> </ng-template>
</tbody> </tbody>
@ -105,7 +113,7 @@
</div> </div>
<div class="grid-footer"> <div class="grid-footer">
<sqx-pager [pager]="contentsPager"></sqx-pager> <sqx-pager [pager]="contentsState.contentsPager | async"></sqx-pager>
</div> </div>
</ng-container> </ng-container>
</sqx-panel> </sqx-panel>
@ -136,7 +144,7 @@
<ng-container footer> <ng-container footer>
<button type="button" class="float-left btn btn-secondary" (click)="cancelStatusChange()">Cancel</button> <button type="button" class="float-left btn btn-secondary" (click)="cancelStatusChange()">Cancel</button>
<button type="button" class="float-right btn btn-primary" [disabled]="dueTimeMode === 'Scheduled' && !dueTime" (click)="confirmStatusChange()">Confirm</button> <button type="button" class="float-right btn btn-primary" autofocus [disabled]="dueTimeMode === 'Scheduled' && !dueTime" (click)="confirmStatusChange()">Confirm</button>
</ng-container> </ng-container>
</sqx-modal-dialog> </sqx-modal-dialog>
</ng-container> </ng-container>

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

@ -6,43 +6,34 @@
*/ */
import { Component, OnDestroy, OnInit } from '@angular/core'; import { Component, OnDestroy, OnInit } from '@angular/core';
import { Observable, Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { import {
AppContext,
AppLanguageDto, AppLanguageDto,
AppsState,
ContentDto, ContentDto,
ContentsService, ContentsState,
DateTime,
ImmutableArray, ImmutableArray,
LanguagesState, LanguagesState,
ModalView, ModalView,
Pager,
SchemaDetailsDto, SchemaDetailsDto,
SchemasState, SchemasState
Versioned
} from '@app/shared'; } from '@app/shared';
@Component({ @Component({
selector: 'sqx-contents-page', selector: 'sqx-contents-page',
styleUrls: ['./contents-page.component.scss'], styleUrls: ['./contents-page.component.scss'],
templateUrl: './contents-page.component.html', templateUrl: './contents-page.component.html'
providers: [
AppContext
]
}) })
export class ContentsPageComponent implements OnDestroy, OnInit { export class ContentsPageComponent implements OnDestroy, OnInit {
private selectedSchemaSubscription: Subscription; private contentsSubscription: Subscription;
private languagesSubscription: Subscription; private languagesSubscription: Subscription;
private selectedSchemaSubscription: Subscription;
public schema: SchemaDetailsDto; public schema: SchemaDetailsDto;
public searchModal = new ModalView(); public searchModal = new ModalView();
public contentItems: ImmutableArray<ContentDto>;
public contentsQuery = '';
public contentsPager = new Pager(0);
public dueTimeDialog = new ModalView(); public dueTimeDialog = new ModalView();
public dueTime: string | null = ''; public dueTime: string | null = '';
public dueTimeFunction: Function | null; public dueTimeFunction: Function | null;
@ -57,19 +48,19 @@ export class ContentsPageComponent implements OnDestroy, OnInit {
public language: AppLanguageDto; public language: AppLanguageDto;
public languages: ImmutableArray<AppLanguageDto>; public languages: ImmutableArray<AppLanguageDto>;
public languageParameter: string;
public isAllSelected = false; public isAllSelected = false;
public isArchive = false;
constructor(public readonly ctx: AppContext, constructor(
public readonly appsState: AppsState,
public readonly contentsState: ContentsState,
private readonly languagesState: LanguagesState, private readonly languagesState: LanguagesState,
private readonly contentsService: ContentsService,
private readonly schemasState: SchemasState private readonly schemasState: SchemasState
) { ) {
} }
public ngOnDestroy() { public ngOnDestroy() {
this.contentsSubscription.unsubscribe();
this.languagesSubscription.unsubscribe(); this.languagesSubscription.unsubscribe();
this.selectedSchemaSubscription.unsubscribe(); this.selectedSchemaSubscription.unsubscribe();
} }
@ -78,10 +69,17 @@ export class ContentsPageComponent implements OnDestroy, OnInit {
this.selectedSchemaSubscription = this.selectedSchemaSubscription =
this.schemasState.selectedSchema this.schemasState.selectedSchema
.subscribe(schema => { .subscribe(schema => {
this.resetSelection();
this.schema = schema!; this.schema = schema!;
this.resetContents(); this.contentsState.load().onErrorResumeNext().subscribe();
this.load(); });
this.contentsSubscription =
this.contentsState.contents
.subscribe(() => {
this.updateSelectionSummary();
}); });
this.languagesSubscription = this.languagesSubscription =
@ -90,180 +88,105 @@ export class ContentsPageComponent implements OnDestroy, OnInit {
this.languages = languages.map(x => x.language); this.languages = languages.map(x => x.language);
this.language = this.languages.at(0); this.language = this.languages.at(0);
}); });
this.contentsState.load().onErrorResumeNext().subscribe();
} }
public publishContent(content: ContentDto) { public reload() {
this.changeContentItems([content], 'Publish', 'Published', false); this.contentsState.load(true).onErrorResumeNext().subscribe();
} }
public publishSelected(scheduled: boolean) { public publish(content: ContentDto) {
const contents = this.contentItems.filter(c => c.status !== 'Published' && this.selectedItems[c.id]).values; this.changeContentItems([content], 'Publish', false);
}
this.changeContentItems(contents, 'Publish', 'Published', false); public publishSelected(scheduled: boolean) {
this.changeContentItems(this.s(c => c.status !== 'Published'), 'Publish', false);
} }
public unpublishContent(content: ContentDto) { public unpublish(content: ContentDto) {
this.changeContentItems([content], 'Unpublish', 'Draft', false); this.changeContentItems([content], 'Unpublish', false);
} }
public unpublishSelected(scheduled: boolean) { public unpublishSelected(scheduled: boolean) {
const contents = this.contentItems.filter(c => c.status === 'Published' && this.selectedItems[c.id]).values; this.changeContentItems(this.s(c => c.status === 'Published'), 'Unpublish', false);
this.changeContentItems(contents, 'Unpublish', 'Draft', false);
} }
public archiveContent(content: ContentDto) { public archive(content: ContentDto) {
this.changeContentItems([content], 'Archive', 'Archived', true); this.changeContentItems([content], 'Archive', true);
} }
public archiveSelected(scheduled: boolean) { public archiveSelected(scheduled: boolean) {
const contents = this.contentItems.filter(c => this.selectedItems[c.id]).values; this.changeContentItems(this.s(), 'Archive', true);
this.changeContentItems(contents, 'Archive', 'Archived', true);
} }
public restoreContent(content: ContentDto) { public restore(content: ContentDto) {
this.changeContentItems([content], 'Restore', 'Draft', true); this.changeContentItems([content], 'Restore', true);
} }
public restoreSelected(scheduled: boolean) { public restoreSelected(scheduled: boolean) {
const contents = this.contentItems.filter(c => this.selectedItems[c.id]).values; this.changeContentItems(this.s(), 'Restore', true);
this.changeContentItems(contents, 'Restore', 'Draft', true);
} }
private changeContentItems(contents: ContentDto[], action: string, status: string, reload: boolean) { private changeContentItems(contents: ContentDto[], action: string, reload: boolean) {
if (contents.length === 0) { if (contents.length === 0) {
return; return;
} }
this.dueTimeFunction = () => { this.dueTimeFunction = () => {
if (this.dueTime) { this.resetSelection();
reload = false;
} this.contentsState.changeStatus(contents, action, this.dueTime).onErrorResumeNext().subscribe();
Observable.forkJoin(
contents
.map(c => this.changeContentItem(c, action, status, this.dueTime, reload)))
.finally(() => {
if (reload) {
this.load();
} else {
this.updateSelectionSummary();
}
})
.subscribe();
}; };
this.dueTimeAction = action; this.dueTimeAction = action;
this.dueTimeDialog.show(); this.dueTimeDialog.show();
} }
private changeContentItem(content: ContentDto, action: string, status: string, dueTime: string | null, reload: boolean): Observable<any> { public deleteSelected() {
return this.contentsService.changeContentStatus(this.ctx.appName, this.schema.name, content.id, action, dueTime, content.version) this.resetSelection();
.catch(error => {
this.ctx.notifyError(error);
return Observable.throw(error);
})
.do(dto => {
if (!reload) {
const dt =
dueTime ?
DateTime.parseISO_UTC(dueTime) :
null;
content = content.changeStatus(status, dt, this.ctx.userToken, dto.version);
this.contentItems = this.contentItems.replaceBy('id', content); this.contentsState.delete(this.s()).onErrorResumeNext().subscribe();
}
});
} }
public deleteSelected(content: ContentDto) { public delete(content: ContentDto) {
Observable.forkJoin( this.resetSelection();
this.contentItems.values.filter(c => this.selectedItems[c.id])
.map(c => this.deleteContentItem(c)))
.finally(() => {
this.load();
})
.subscribe();
}
public deleteContent(content: ContentDto) { this.contentsState.delete([content]).onErrorResumeNext().subscribe();
this.deleteContentItem(content)
.finally(() => {
this.load();
})
.subscribe();
} }
public deleteContentItem(content: ContentDto): Observable<any> { public goArchive(isArchive: boolean) {
return this.contentsService.deleteContent(this.ctx.appName, this.schema.name, content.id, content.version) this.resetSelection();
.catch(error => {
this.ctx.notifyError(error);
return Observable.throw(error); this.contentsState.goArchive(isArchive).onErrorResumeNext().subscribe();
});
} }
public onContentSaved(content: ContentDto, update: Versioned<any>) { public goPrev() {
content = content.update(update.payload, this.ctx.userToken, update.version); this.resetSelection();
this.contentItems = this.contentItems.replaceBy('id', content);
}
public load(showInfo = false) {
this.contentsService.getContents(this.ctx.appName, this.schema.name, this.contentsPager.pageSize, this.contentsPager.skip, this.contentsQuery, undefined, this.isArchive)
.finally(() => {
this.selectedItems = {};
this.updateSelectionSummary();
})
.subscribe(dtos => {
this.contentItems = ImmutableArray.of(dtos.items);
this.contentsPager = this.contentsPager.setCount(dtos.total);
if (showInfo) {
this.ctx.notifyInfo('Contents reloaded.');
}
}, error => {
this.ctx.notifyError(error);
});
}
public updateArchive(isArchive: boolean) {
this.contentsPager = new Pager(0);
this.isArchive = isArchive;
this.searchModal.hide();
this.load();
}
public search(query: string) {
this.contentsQuery = query;
this.contentsPager = new Pager(0);
this.load(); this.contentsState.goPrev().onErrorResumeNext().subscribe();
} }
public goNext() { public goNext() {
this.contentsPager = this.contentsPager.goNext(); this.resetSelection();
this.load(); this.contentsState.goNext().onErrorResumeNext().subscribe();
} }
public goPrev() { public search(query: string) {
this.contentsPager = this.contentsPager.goPrev(); this.resetSelection();
this.load(); this.contentsState.search(query).onErrorResumeNext().subscribe();
} }
public isItemSelected(content: ContentDto): boolean { public isItemSelected(content: ContentDto): boolean {
return !!this.selectedItems[content.id]; return !!this.selectedItems[content.id];
} }
public selectLanguage(language: AppLanguageDto) {
this.language = language;
}
public selectItem(content: ContentDto, isSelected: boolean) { public selectItem(content: ContentDto, isSelected: boolean) {
this.selectedItems[content.id] = isSelected; this.selectedItems[content.id] = isSelected;
@ -274,65 +197,66 @@ export class ContentsPageComponent implements OnDestroy, OnInit {
this.selectedItems = {}; this.selectedItems = {};
if (isSelected) { if (isSelected) {
for (let c of this.contentItems.values) { for (let content of this.contentsState.snapshot.contents.values) {
this.selectedItems[c.id] = true; this.selectedItems[content.id] = true;
} }
} }
this.updateSelectionSummary(); this.updateSelectionSummary();
} }
private updateSelectionSummary() { public confirmStatusChange() {
this.isAllSelected = this.contentItems.length > 0; this.dueTimeFunction!();
this.selectionCount = 0; this.dueTimeFunction = null;
this.canPublish = true; this.dueTimeMode = 'Immediately';
this.canUnpublish = true; this.dueTimeDialog.hide();
this.dueTime = null;
for (let c of this.contentItems.values) {
if (this.selectedItems[c.id]) {
this.selectionCount++;
if (c.status !== 'Published') {
this.canUnpublish = false;
}
if (c.status === 'Published') {
this.canPublish = false;
}
} else {
this.isAllSelected = false;
}
}
} }
public selectLanguage(language: AppLanguageDto) { public cancelStatusChange() {
this.language = language; this.dueTimeMode = 'Immediately';
this.dueTimeDialog.hide();
this.dueTimeFunction = null;
this.dueTime = null;
} }
public trackByContent(content: ContentDto): string { public trackByContent(content: ContentDto): string {
return content.id; return content.id;
} }
private resetContents() { private s(predicate?: (content: ContentDto) => boolean) {
this.contentItems = ImmutableArray.empty<ContentDto>(); return this.contentsState.snapshot.contents.values.filter(c => this.selectedItems[c.id] && (!predicate || predicate(c)));
this.contentsQuery = ''; }
this.contentsPager = new Pager(0);
private resetSelection() {
this.selectedItems = {}; this.selectedItems = {};
this.updateSelectionSummary(); this.updateSelectionSummary();
} }
public confirmStatusChange() { private updateSelectionSummary() {
this.dueTimeFunction!(); this.isAllSelected = this.contentsState.snapshot.contents.length > 0;
this.selectionCount = 0;
this.canPublish = true;
this.canUnpublish = true;
for (let content of this.contentsState.snapshot.contents.values) {
if (this.selectedItems[content.id]) {
this.selectionCount++;
this.cancelStatusChange(); if (content.status !== 'Published') {
this.canUnpublish = false;
} }
public cancelStatusChange() { if (content.status === 'Published') {
this.dueTimeMode = 'Immediately'; this.canPublish = false;
this.dueTimeDialog.hide(); }
this.dueTimeFunction = null; } else {
this.dueTime = null; this.isAllSelected = false;
}
}
} }
} }

2
src/Squidex/app/features/content/pages/contents/search-form.component.ts

@ -69,6 +69,7 @@ export class SearchFormComponent implements OnChanges {
let odataFilter = ''; let odataFilter = '';
let odataSearch = ''; let odataSearch = '';
if (this.query) {
const parts = this.query.split('&'); const parts = this.query.split('&');
if (parts.length === 1 && parts[0][0] !== '$') { if (parts.length === 1 && parts[0][0] !== '$') {
@ -90,6 +91,7 @@ export class SearchFormComponent implements OnChanges {
} }
} }
} }
}
this.searchForm.setValue({ this.searchForm.setValue({
odataFilter, odataFilter,

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

@ -5,9 +5,11 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { Version } from '@app/shared';
export class ContentVersionSelected { export class ContentVersionSelected {
constructor( constructor(
public readonly version: number public readonly version: Version
) { ) {
} }
} }

10
src/Squidex/app/features/content/shared/assets-editor.component.ts

@ -49,14 +49,18 @@ export class AssetsEditorComponent implements ControlValueAccessor {
} }
public writeValue(value: string[]) { public writeValue(value: string[]) {
this.oldAssets = ImmutableArray.empty<AssetDto>(); if (Types.isArrayOfString(value) && !Types.isEquals(value, this.oldAssets.map(x => x.id).values)) {
if (Types.isArrayOfString(value) && value.length > 0) {
const assetIds: string[] = value; const assetIds: string[] = value;
this.assetsService.getAssets(this.appsState.appName, 0, 0, undefined, value) this.assetsService.getAssets(this.appsState.appName, 0, 0, undefined, value)
.subscribe(dtos => { .subscribe(dtos => {
this.oldAssets = ImmutableArray.of(assetIds.map(id => dtos.items.find(x => x.id === id)).filter(a => !!a).map(a => a!)); this.oldAssets = ImmutableArray.of(assetIds.map(id => dtos.items.find(x => x.id === id)).filter(a => !!a).map(a => a!));
if (this.oldAssets.length !== assetIds.length) {
this.updateValue();
}
}, () => {
this.oldAssets = ImmutableArray.empty<AssetDto>();
}); });
} }
} }

22
src/Squidex/app/features/content/shared/content-item.component.html

@ -6,23 +6,23 @@
</td> </td>
<td class="cell-auto" *ngFor="let field of schema.listFields; let i = index" (click)="shouldStop($event)"> <td class="cell-auto" *ngFor="let field of schema.listFields; let i = index" (click)="shouldStop($event)">
<div *ngIf="field.properties.inlineEditable && !isReadOnly" [formGroup]="form" (click)="$event.stopPropagation()"> <div *ngIf="field.properties['inlineEditable'] && !isReadOnly" [formGroup]="patchForm.form" (click)="$event.stopPropagation()">
<div [ngSwitch]="field.properties.fieldType"> <div [ngSwitch]="field.properties.fieldType">
<div *ngSwitchCase="'Number'"> <div *ngSwitchCase="'Number'">
<div [ngSwitch]="field.properties.editor"> <div [ngSwitch]="field.properties['editor']">
<div *ngSwitchCase="'Input'"> <div *ngSwitchCase="'Input'">
<input class="form-control" type="number" [formControlName]="field.name" [placeholder]="field.displayPlaceholder" /> <input class="form-control" type="number" [formControlName]="field.name" [placeholder]="field.displayPlaceholder" />
</div> </div>
<div *ngSwitchCase="'Dropdown'"> <div *ngSwitchCase="'Dropdown'">
<select class="form-control" [formControlName]="field.name"> <select class="form-control" [formControlName]="field.name">
<option [ngValue]="null"></option> <option [ngValue]="null"></option>
<option *ngFor="let value of field.properties.allowedValues" [ngValue]="value">{{value}}</option> <option *ngFor="let value of field.properties['allowedValues']" [ngValue]="value">{{value}}</option>
</select> </select>
</div> </div>
</div> </div>
</div> </div>
<div *ngSwitchCase="'String'"> <div *ngSwitchCase="'String'">
<div [ngSwitch]="field.properties.editor"> <div [ngSwitch]="field.properties['editor']">
<div *ngSwitchCase="'Input'"> <div *ngSwitchCase="'Input'">
<input class="form-control" type="text" [formControlName]="field.name" [placeholder]="field.displayPlaceholder" /> <input class="form-control" type="text" [formControlName]="field.name" [placeholder]="field.displayPlaceholder" />
</div> </div>
@ -32,13 +32,13 @@
<div *ngSwitchCase="'Dropdown'"> <div *ngSwitchCase="'Dropdown'">
<select class="form-control" [formControlName]="field.name"> <select class="form-control" [formControlName]="field.name">
<option [ngValue]="null"></option> <option [ngValue]="null"></option>
<option *ngFor="let value of field.properties.allowedValues" [ngValue]="value">{{value}}</option> <option *ngFor="let value of field.properties['allowedValues']" [ngValue]="value">{{value}}</option>
</select> </select>
</div> </div>
</div> </div>
</div> </div>
<div *ngSwitchCase="'Boolean'"> <div *ngSwitchCase="'Boolean'">
<div [ngSwitch]="field.properties.editor"> <div [ngSwitch]="field.properties['editor']">
<div *ngSwitchCase="'Toggle'"> <div *ngSwitchCase="'Toggle'">
<sqx-toggle [formControlName]="field.name"></sqx-toggle> <sqx-toggle [formControlName]="field.name"></sqx-toggle>
</div> </div>
@ -51,7 +51,7 @@
</div> </div>
</div> </div>
</div> </div>
<div *ngIf="!field.properties.inlineEditable || isReadOnly" class="truncate"> <div *ngIf="!field.properties['inlineEditable'] || isReadOnly" class="truncate">
{{values[i]}} {{values[i]}}
</div> </div>
</td> </td>
@ -75,22 +75,22 @@
<small class="item-modified">{{content.lastModified | sqxFromNow}}</small> <small class="item-modified">{{content.lastModified | sqxFromNow}}</small>
</td> </td>
<td class="cell-user" *ngIf="form.dirty" (click)="shouldStop($event)"> <td class="cell-user" *ngIf="patchForm.form.dirty" (click)="shouldStop($event)">
<button type="button" class="btn btn-success" (click)="save(); $event.stopPropagation()"> <button type="button" class="btn btn-success" (click)="save(); $event.stopPropagation()">
<i class="icon-checkmark"></i> <i class="icon-checkmark"></i>
</button> </button>
</td> </td>
<td class="cell-actions" *ngIf="form.dirty" (click)="shouldStop($event)"> <td class="cell-actions" *ngIf="patchForm.form.dirty" (click)="shouldStop($event)">
<button type="button" class="btn btn-link btn-secondary btn-cancel" (click)="save(); $event.stopPropagation()"> <button type="button" class="btn btn-link btn-secondary btn-cancel" (click)="save(); $event.stopPropagation()">
<i class="icon-close"></i> <i class="icon-close"></i>
</button> </button>
</td> </td>
<td class="cell-user" *ngIf="form.pristine" (click)="shouldStop($event)"> <td class="cell-user" *ngIf="patchForm.form.pristine" (click)="shouldStop($event)">
<img class="user-picture" [attr.title]="content.lastModifiedBy | sqxUserNameRef" [attr.src]="content.lastModifiedBy | sqxUserPictureRef" /> <img class="user-picture" [attr.title]="content.lastModifiedBy | sqxUserNameRef" [attr.src]="content.lastModifiedBy | sqxUserPictureRef" />
</td> </td>
<td class="cell-actions" *ngIf="!isReadOnly && form.pristine" (click)="shouldStop($event)"> <td class="cell-actions" *ngIf="!isReadOnly && patchForm.form.pristine" (click)="shouldStop($event)">
<div class="dropdown dropdown-options" *ngIf="content"> <div class="dropdown dropdown-options" *ngIf="content">
<button type="button" class="btn btn-link btn-secondary" (click)="dropdown.toggle(); $event.stopPropagation()" [class.active]="dropdown.isOpen | async" #optionsButton> <button type="button" class="btn btn-link btn-secondary" (click)="dropdown.toggle(); $event.stopPropagation()" [class.active]="dropdown.isOpen | async" #optionsButton>
<i class="icon-dots"></i> <i class="icon-dots"></i>

84
src/Squidex/app/features/content/shared/content-item.component.ts

@ -5,21 +5,19 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'; import { Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { import {
AppContext,
AppLanguageDto, AppLanguageDto,
ContentDto, ContentDto,
ContentsService, ContentsState,
fadeAnimation, fadeAnimation,
FieldDto, FieldDto,
fieldInvariant, fieldInvariant,
ModalView, ModalView,
PatchContentForm,
SchemaDetailsDto, SchemaDetailsDto,
Types, Types
Versioned
} from '@app/shared'; } from '@app/shared';
/* tslint:disable:component-selector */ /* tslint:disable:component-selector */
@ -28,19 +26,13 @@ import {
selector: '[sqxContent]', selector: '[sqxContent]',
styleUrls: ['./content-item.component.scss'], styleUrls: ['./content-item.component.scss'],
templateUrl: './content-item.component.html', templateUrl: './content-item.component.html',
providers: [
AppContext
],
animations: [ animations: [
fadeAnimation fadeAnimation
] ]
}) })
export class ContentItemComponent implements OnInit, OnChanges { export class ContentItemComponent implements OnChanges {
@Output() @Output()
public publishing = new EventEmitter(); public deleting = new EventEmitter();
@Output()
public unpublishing = new EventEmitter();
@Output() @Output()
public archiving = new EventEmitter(); public archiving = new EventEmitter();
@ -49,10 +41,10 @@ export class ContentItemComponent implements OnInit, OnChanges {
public restoring = new EventEmitter(); public restoring = new EventEmitter();
@Output() @Output()
public deleting = new EventEmitter(); public publishing = new EventEmitter();
@Output() @Output()
public saved = new EventEmitter<Versioned<any>>(); public unpublishing = new EventEmitter();
@Output() @Output()
public selectedChange = new EventEmitter(); public selectedChange = new EventEmitter();
@ -75,77 +67,43 @@ export class ContentItemComponent implements OnInit, OnChanges {
@Input('sqxContent') @Input('sqxContent')
public content: ContentDto; public content: ContentDto;
public formSubmitted = false; public patchForm: PatchContentForm;
public form: FormGroup = new FormGroup({});
public dropdown = new ModalView(false, true); public dropdown = new ModalView(false, true);
public values: any[] = []; public values: any[] = [];
constructor(public readonly ctx: AppContext, constructor(
private readonly contentsService: ContentsService private readonly contentsState: ContentsState
) { ) {
} }
public ngOnChanges() { public ngOnChanges() {
this.updateValues(); this.patchForm = new PatchContentForm(this.schema, this.language);
}
public ngOnInit() {
for (let field of this.schema.listFields) {
if (field.properties['inlineEditable']) {
this.form.setControl(field.name, new FormControl(undefined, field.createValidators(this.language.isOptional)));
}
}
this.updateValues(); this.updateValues();
} }
public shouldStop(event: Event) { public shouldStop(event: Event) {
if (this.form.dirty) { if (this.patchForm.form.dirty) {
event.stopPropagation(); event.stopPropagation();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
} }
} }
public save() { public save() {
this.formSubmitted = true; const value = this.patchForm.submit();
if (this.form.dirty && this.form.valid) { if (value) {
this.form.disable(); this.contentsState.patch(this.content, value)
.subscribe(() => {
const request = {}; this.patchForm.submitCompleted();
for (let field of this.schema.listFields) {
if (field.properties['inlineEditable']) {
const value = this.form.controls[field.name].value;
if (field.isLocalizable) {
request[field.name] = { [this.language.iso2Code]: value };
} else {
request[field.name] = { iv: value };
}
}
}
this.contentsService.patchContent(this.ctx.appName, this.schema.name, this.content.id, request, this.content.version)
.finally(() => {
this.form.enable();
})
.subscribe(dto => {
this.form.markAsPristine();
this.emitSaved(dto);
}, error => { }, error => {
this.ctx.notifyError(error); this.patchForm.submitFailed(error);
}); });
} }
} }
private emitSaved(data: Versioned<any>) {
this.saved.emit(data);
}
private updateValues() { private updateValues() {
this.values = []; this.values = [];
@ -158,8 +116,8 @@ export class ContentItemComponent implements OnInit, OnChanges {
this.values.push(field.formatValue(value)); this.values.push(field.formatValue(value));
} }
if (this.form) { if (this.patchForm) {
const formControl = this.form.controls[field.name]; const formControl = this.patchForm.form.controls[field.name];
if (formControl) { if (formControl) {
formControl.setValue(value); formControl.setValue(value);

18
src/Squidex/app/features/content/shared/contents-selector.component.html

@ -9,17 +9,21 @@
<i class="icon-reset"></i> Refresh <i class="icon-reset"></i> Refresh
</button> </button>
<sqx-search-form (queryChanged)="search($event)" [canArchive]="false"></sqx-search-form> <sqx-search-form
[query]="contentsState.contentsQuery | async"
(queryChanged)="search($event)"
[canArchive]="false">
</sqx-search-form>
<ng-container *ngIf="languages.length > 1"> <ng-container *ngIf="languages.length > 1">
<sqx-language-selector class="languages-buttons" (selectedLanguageChanged)="selectLanguage($event)" [languages]="languages.values"></sqx-language-selector> <sqx-language-selector class="languages-buttons" (selectedLanguageChange)="selectLanguage($event)" [languages]="languages.values"></sqx-language-selector>
</ng-container> </ng-container>
</div> </div>
</ng-container> </ng-container>
<ng-container content> <ng-container content>
<div class="grid-header"> <div class="grid-header">
<table class="table table-items table-fixed" *ngIf="contentItems"> <table class="table table-items table-fixed">
<thead> <thead>
<tr> <tr>
<th class="cell-select"> <th class="cell-select">
@ -41,12 +45,12 @@
<div class="grid-content"> <div class="grid-content">
<div sqxIgnoreScrollbar> <div sqxIgnoreScrollbar>
<table class="table table-items table-fixed" *ngIf="contentItems"> <table class="table table-items table-fixed">
<tbody> <tbody>
<ng-template ngFor let-content [ngForOf]="contentItems" [ngForTrackBy]="trackByContent"> <ng-template ngFor let-content [ngForOf]="contentsState.contents | async" [ngForTrackBy]="trackByContent">
<tr [sqxContent]="content" <tr [sqxContent]="content"
[selected]="isItemSelected(content)" [selected]="isItemSelected(content)"
(selectedChange)="onContentSelected(content)" (selectedChange)="selectContent(content)"
[language]="language" [language]="language"
[schema]="schema" [schema]="schema"
isReadOnly="true"></tr> isReadOnly="true"></tr>
@ -58,7 +62,7 @@
</div> </div>
<div class="grid-footer"> <div class="grid-footer">
<sqx-pager [pager]="contentsPager"></sqx-pager> <sqx-pager [pager]="contentsState.contentsPager | async"></sqx-pager>
</div> </div>
</ng-container> </ng-container>

71
src/Squidex/app/features/content/shared/contents-selector.component.ts

@ -8,21 +8,20 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { import {
AppsState,
ContentDto, ContentDto,
ContentsService,
DialogService,
ImmutableArray,
LanguageDto, LanguageDto,
ManualContentsState,
ModalView, ModalView,
Pager,
SchemaDetailsDto SchemaDetailsDto
} from '@app/shared'; } from '@app/shared';
@Component({ @Component({
selector: 'sqx-contents-selector', selector: 'sqx-contents-selector',
styleUrls: ['./contents-selector.component.scss'], styleUrls: ['./contents-selector.component.scss'],
templateUrl: './contents-selector.component.html' templateUrl: './contents-selector.component.html',
providers: [
ManualContentsState
]
}) })
export class ContentsSelectorComponent implements OnInit { export class ContentsSelectorComponent implements OnInit {
@Input() @Input()
@ -39,76 +38,42 @@ export class ContentsSelectorComponent implements OnInit {
public searchModal = new ModalView(); public searchModal = new ModalView();
public contentItems: ImmutableArray<ContentDto>;
public contentsQuery = '';
public contentsPager = new Pager(0);
public selectedItems: { [id: string]: ContentDto; } = {}; public selectedItems: { [id: string]: ContentDto; } = {};
public selectionCount = 0; public selectionCount = 0;
public isAllSelected = false; public isAllSelected = false;
constructor( constructor(
private readonly appsState: AppsState, public readonly contentsState: ManualContentsState
private readonly contentsService: ContentsService,
private readonly dialogs: DialogService
) { ) {
} }
public ngOnInit() { public ngOnInit() {
this.load(); this.contentsState.schema = this.schema;
}
public reload() { this.contentsState.load().onErrorResumeNext().subscribe();
this.load(true);
} }
private load(notifyLod = false) { public reload() {
this.contentsService.getContents(this.appsState.appName, this.schema.name, this.contentsPager.pageSize, this.contentsPager.skip, this.contentsQuery, undefined, false) this.contentsState.load(true).onErrorResumeNext().subscribe();
.finally(() => {
this.selectedItems = {};
this.updateSelectionSummary();
})
.subscribe(dtos => {
this.contentItems = ImmutableArray.of(dtos.items);
this.contentsPager = this.contentsPager.setCount(dtos.total);
if (notifyLod) {
this.dialogs.notifyInfo('Contents reloaded.');
}
}, error => {
this.dialogs.notifyError(error);
});
} }
public search(query: string) { public search(query: string) {
this.contentsQuery = query; this.contentsState.search(query).onErrorResumeNext().subscribe();
this.contentsPager = new Pager(0);
this.load();
} }
public goNext() { public goNext() {
this.contentsPager = this.contentsPager.goNext(); this.contentsState.goNext().onErrorResumeNext().subscribe();
this.load();
} }
public goPrev() { public goPrev() {
this.contentsPager = this.contentsPager.goPrev(); this.contentsState.goPrev().onErrorResumeNext().subscribe();
this.load();
} }
public isItemSelected(content: ContentDto) { public isItemSelected(content: ContentDto) {
return this.selectedItems[content.id]; return this.selectedItems[content.id];
} }
public selectLanguage(language: LanguageDto) {
this.language = language;
}
public complete() { public complete() {
this.selected.emit([]); this.selected.emit([]);
} }
@ -117,11 +82,15 @@ export class ContentsSelectorComponent implements OnInit {
this.selected.emit(Object.values(this.selectedItems)); this.selected.emit(Object.values(this.selectedItems));
} }
public selectLanguage(language: LanguageDto) {
this.language = language;
}
public selectAll(isSelected: boolean) { public selectAll(isSelected: boolean) {
this.selectedItems = {}; this.selectedItems = {};
if (isSelected) { if (isSelected) {
for (let content of this.contentItems.values) { for (let content of this.contentsState.snapshot.contents.values) {
this.selectedItems[content.id] = content; this.selectedItems[content.id] = content;
} }
} }
@ -129,7 +98,7 @@ export class ContentsSelectorComponent implements OnInit {
this.updateSelectionSummary(); this.updateSelectionSummary();
} }
public onContentSelected(content: ContentDto) { public selectContent(content: ContentDto) {
if (this.selectedItems[content.id]) { if (this.selectedItems[content.id]) {
delete this.selectedItems[content.id]; delete this.selectedItems[content.id];
} else { } else {
@ -142,7 +111,7 @@ export class ContentsSelectorComponent implements OnInit {
private updateSelectionSummary() { private updateSelectionSummary() {
this.selectionCount = Object.keys(this.selectedItems).length; this.selectionCount = Object.keys(this.selectedItems).length;
this.isAllSelected = this.selectionCount === this.contentItems.length; this.isAllSelected = this.selectionCount === this.contentsState.snapshot.contents.length;
} }
public trackByContent(content: ContentDto): string { public trackByContent(content: ContentDto): string {

20
src/Squidex/app/features/content/shared/references-editor.component.html

@ -1,27 +1,29 @@
<div class="references-container" [class.disabled]="isDisabled"> <div class="references-container" [class.disabled]="isDisabled">
<div class="drop-area-container" *ngIf="schema"> <ng-container *ngIf="schema">
<div class="drop-area-container">
<div class="drop-area" (click)="showModal()"> <div class="drop-area" (click)="showModal()">
Click here to link a content. Click here to link a content.
</div> </div>
</div> </div>
<div class="invalid" *ngIf="isInvalidSchema"> <table class="table table-items table-fixed" [class.disabled]="isDisabled" *ngIf="schema && contentItems && contentItems.length > 0">
Schema not found or not configured yet.
</div>
<table class="table table-items table-fixed" [class.disabled]="isDisabled" *ngIf="contentItems && contentItems.length > 0">
<tbody dnd-sortable-container [sortableData]="contentItems.mutableValues"> <tbody dnd-sortable-container [sortableData]="contentItems.mutableValues">
<ng-template ngFor let-content let-i="index" [ngForOf]="contentItems"> <ng-template ngFor let-content let-i="index" [ngForOf]="contentItems">
<tr [sqxContent]="content" dnd-sortable [sortableIndex]="i" (sqxSorted)="onContentsSorted($event)" <tr [sqxContent]="content" dnd-sortable [sortableIndex]="i" (sqxSorted)="sort($event)"
[language]="language" [language]="language"
[schema]="schema" [schema]="schema"
(deleting)="onContentRemoving(content)" (removing)="remove(content)"
isReadOnly="true" isReadOnly="true"
isReference="true"></tr> isReference="true"></tr>
<tr class="spacer"></tr> <tr class="spacer"></tr>
</ng-template> </ng-template>
</tbody> </tbody>
</table> </table>
</ng-container>
<div class="invalid" *ngIf="isInvalidSchema">
Schema not found or not configured yet.
</div>
</div> </div>
<ng-container *sqxModalView="isModalVisibible;onRoot:true;closeAuto:false"> <ng-container *sqxModalView="isModalVisibible;onRoot:true;closeAuto:false">
@ -29,6 +31,6 @@
[language]="language" [language]="language"
[languages]="languages" [languages]="languages"
[schema]="schema" [schema]="schema"
(selected)="onContentsSelected($event)"> (selected)="select($event)">
</sqx-contents-selector> </sqx-contents-selector>
</ng-container> </ng-container>

26
src/Squidex/app/features/content/shared/references-editor.component.scss

@ -5,17 +5,6 @@
pointer-events: none; pointer-events: none;
} }
.references {
&-container {
& {
background: $color-background;
overflow-x: hidden;
overflow-y: scroll;
padding: 1rem;
}
}
}
.invalid { .invalid {
padding: 2rem; padding: 2rem;
font-size: 1.2rem; font-size: 1.2rem;
@ -24,6 +13,15 @@
color: darken($color-border, 30%); color: darken($color-border, 30%);
} }
.references-container {
background: $color-background;
overflow-x: hidden;
overflow-y: auto;
padding: 1rem;
min-height: 2rem;
max-height: 20rem;
}
.drop-area { .drop-area {
& { & {
@include transition(border-color .4s ease); @include transition(border-color .4s ease);
@ -43,12 +41,6 @@
} }
.table { .table {
& {
margin-bottom: -.25rem; margin-bottom: -.25rem;
margin-top: 1rem; margin-top: 1rem;
}
&.disabled {
margin-top: 0;
}
} }

17
src/Squidex/app/features/content/shared/references-editor.component.ts

@ -65,6 +65,7 @@ export class ReferencesEditorComponent implements ControlValueAccessor, OnInit {
public ngOnInit() { public ngOnInit() {
if (this.schemaId === MathHelper.EMPTY_GUID) { if (this.schemaId === MathHelper.EMPTY_GUID) {
this.isInvalidSchema = true;
return; return;
} }
@ -77,14 +78,18 @@ export class ReferencesEditorComponent implements ControlValueAccessor, OnInit {
} }
public writeValue(value: string[]) { public writeValue(value: string[]) {
this.contentItems = ImmutableArray.empty<ContentDto>(); if (Types.isArrayOfString(value) && !Types.isEquals(value, this.contentItems.map(x => x.id).values)) {
if (Types.isArrayOfString(value) && value.length > 0) {
const contentIds: string[] = value; const contentIds: string[] = value;
this.contentsService.getContents(this.appsState.appName, this.schemaId, 10000, 0, undefined, contentIds) this.contentsService.getContents(this.appsState.appName, this.schemaId, 10000, 0, undefined, contentIds)
.subscribe(dtos => { .subscribe(dtos => {
this.contentItems = ImmutableArray.of(contentIds.map(id => dtos.items.find(c => c.id === id)).filter(r => !!r).map(r => r!)); this.contentItems = ImmutableArray.of(contentIds.map(id => dtos.items.find(c => c.id === id)).filter(r => !!r).map(r => r!));
if (this.contentItems.length !== contentIds.length) {
this.updateValue();
}
}, () => {
this.contentItems = ImmutableArray.empty<ContentDto>();
}); });
} }
} }
@ -109,7 +114,7 @@ export class ReferencesEditorComponent implements ControlValueAccessor, OnInit {
this.isModalVisibible = false; this.isModalVisibible = false;
} }
public onContentsSelected(contents: ContentDto[]) { public select(contents: ContentDto[]) {
for (let content of contents) { for (let content of contents) {
this.contentItems = this.contentItems.push(content); this.contentItems = this.contentItems.push(content);
} }
@ -121,7 +126,7 @@ export class ReferencesEditorComponent implements ControlValueAccessor, OnInit {
this.hideModal(); this.hideModal();
} }
public onContentRemoving(content: ContentDto) { public remove(content: ContentDto) {
if (content) { if (content) {
this.contentItems = this.contentItems.remove(content); this.contentItems = this.contentItems.remove(content);
@ -129,7 +134,7 @@ export class ReferencesEditorComponent implements ControlValueAccessor, OnInit {
} }
} }
public onContentsSorted(contents: ContentDto[]) { public sort(contents: ContentDto[]) {
if (contents) { if (contents) {
this.contentItems = ImmutableArray.of(contents); this.contentItems = ImmutableArray.of(contents);

2
src/Squidex/app/features/rules/pages/events/rule-events-page.component.html

@ -1,4 +1,4 @@
<sqx-title message="{app} | Rules Events" parameter1="app" [value1]="ctx.appName"></sqx-title> <sqx-title message="{app} | Rules Events" parameter1="app" [value1]="appsState.appName"></sqx-title>
<sqx-panel desiredWidth="63rem"> <sqx-panel desiredWidth="63rem">
<ng-container title> <ng-container title>

24
src/Squidex/app/features/rules/pages/events/rule-events-page.component.ts

@ -8,7 +8,8 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { import {
AppContext, AppsState,
DialogService,
ImmutableArray, ImmutableArray,
Pager, Pager,
RuleEventDto, RuleEventDto,
@ -18,10 +19,7 @@ import {
@Component({ @Component({
selector: 'sqx-rule-events-page', selector: 'sqx-rule-events-page',
styleUrls: ['./rule-events-page.component.scss'], styleUrls: ['./rule-events-page.component.scss'],
templateUrl: './rule-events-page.component.html', templateUrl: './rule-events-page.component.html'
providers: [
AppContext
]
}) })
export class RuleEventsPageComponent implements OnInit { export class RuleEventsPageComponent implements OnInit {
public eventsItems = ImmutableArray.empty<RuleEventDto>(); public eventsItems = ImmutableArray.empty<RuleEventDto>();
@ -29,7 +27,9 @@ export class RuleEventsPageComponent implements OnInit {
public selectedEventId: string | null = null; public selectedEventId: string | null = null;
constructor(public readonly ctx: AppContext, constructor(
public readonly appsState: AppsState,
private readonly dialogs: DialogService,
private readonly rulesService: RulesService private readonly rulesService: RulesService
) { ) {
} }
@ -39,25 +39,25 @@ export class RuleEventsPageComponent implements OnInit {
} }
public load(notifyLoad = false) { public load(notifyLoad = false) {
this.rulesService.getEvents(this.ctx.appName, this.eventsPager.pageSize, this.eventsPager.skip) this.rulesService.getEvents(this.appsState.appName, this.eventsPager.pageSize, this.eventsPager.skip)
.subscribe(dtos => { .subscribe(dtos => {
this.eventsItems = ImmutableArray.of(dtos.items); this.eventsItems = ImmutableArray.of(dtos.items);
this.eventsPager = this.eventsPager.setCount(dtos.total); this.eventsPager = this.eventsPager.setCount(dtos.total);
if (notifyLoad) { if (notifyLoad) {
this.ctx.notifyInfo('Events reloaded.'); this.dialogs.notifyInfo('Events reloaded.');
} }
}, error => { }, error => {
this.ctx.notifyError(error); this.dialogs.notifyError(error);
}); });
} }
public enqueueEvent(event: RuleEventDto) { public enqueueEvent(event: RuleEventDto) {
this.rulesService.enqueueEvent(this.ctx.appName, event.id) this.rulesService.enqueueEvent(this.appsState.appName, event.id)
.subscribe(() => { .subscribe(() => {
this.ctx.notifyInfo('Events enqueued. Will be resend in a few seconds.'); this.dialogs.notifyInfo('Events enqueued. Will be resend in a few seconds.');
}, error => { }, error => {
this.ctx.notifyError(error); this.dialogs.notifyError(error);
}); });
} }

18
src/Squidex/app/framework/utils/types.ts

@ -67,4 +67,22 @@ export module Types {
return true; return true;
} }
export function isEquals<T>(lhs: T[], rhs: T[]) {
if (!lhs && !rhs) {
return true;
}
if (lhs.length !== rhs.length) {
return false;
}
for (let i = 0; i < lhs.length; i++) {
if (rhs[i] !== lhs[i]) {
return false;
}
}
return true;
}
} }

69
src/Squidex/app/shared/components/app-context.ts

@ -1,69 +0,0 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Injectable } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs';
import { MessageBus } from '@app/framework';
import {
AppDto,
AppsState,
AuthService,
DialogService,
ErrorDto,
Profile
} from '@app/shared/internal';
@Injectable()
export class AppContext {
public get app(): AppDto {
return this.appsState.snapshot.selectedApp!;
}
public get appChanges(): Observable<AppDto | null> {
return this.appsState.selectedApp;
}
public get appName(): string {
return this.app.name;
}
public get userToken(): string {
return this.authService.user!.token;
}
public get userId(): string {
return this.authService.user!.id;
}
public get user(): Profile {
return this.authService.user!;
}
constructor(
public readonly dialogs: DialogService,
public readonly authService: AuthService,
public readonly appsState: AppsState,
public readonly route: ActivatedRoute,
public readonly bus: MessageBus
) {
}
public confirmUnsavedChanges(): Observable<boolean> {
return this.dialogs.confirmUnsavedChanges();
}
public notifyInfo(error: string) {
this.dialogs.notifyInfo(error);
}
public notifyError(error: string | ErrorDto) {
this.dialogs.notifyError(error);
}
}

12
src/Squidex/app/shared/components/assets-list.component.html

@ -15,18 +15,18 @@
</div> </div>
<div class="row"> <div class="row">
<sqx-asset class="{{assetClass}}" *ngFor="let file of newFiles" [initFile]="file" <sqx-asset *ngFor="let file of newFiles" [initFile]="file"
(failed)="onAssetFailed(file)" (failed)="remove(file)"
(loaded)="onAssetLoaded(file, $event)"> (loaded)="add(file, $event)">
</sqx-asset> </sqx-asset>
<ng-container *ngIf="state.assets | async; let assets"> <ng-container *ngIf="state.assets | async; let assets">
<sqx-asset class="{{assetClass}}" *ngFor="let asset of assets; trackBy: trackByAsset" [asset]="asset" <sqx-asset *ngFor="let asset of assets; trackBy: trackByAsset" [asset]="asset"
[isDisabled]="isDisabled" [isDisabled]="isDisabled"
[isSelectable]="selectedIds" [isSelectable]="selectedIds"
[isSelected]="isSelected(asset)" [isSelected]="isSelected(asset)"
(selected)="onAssetSelected($event)" (selected)="select($event)"
(deleting)="onAssetDeleting($event)"> (deleting)="delete($event)">
</sqx-asset> </sqx-asset>
</ng-container> </ng-container>
</div> </div>

23
src/Squidex/app/shared/components/assets-list.component.ts

@ -32,13 +32,10 @@ export class AssetsListComponent {
@Input() @Input()
public selectedIds: object; public selectedIds: object;
@Input()
public assetClass = '';
@Output() @Output()
public selected = new EventEmitter<AssetDto>(); public selected = new EventEmitter<AssetDto>();
public onAssetLoaded(file: File, asset: AssetDto) { public add(file: File, asset: AssetDto) {
this.newFiles = this.newFiles.remove(file); this.newFiles = this.newFiles.remove(file);
this.state.add(asset); this.state.add(asset);
@ -48,18 +45,10 @@ export class AssetsListComponent {
this.state.load().onErrorResumeNext().subscribe(); this.state.load().onErrorResumeNext().subscribe();
} }
public onAssetDeleting(asset: AssetDto) { public delete(asset: AssetDto) {
this.state.delete(asset).onErrorResumeNext().subscribe(); this.state.delete(asset).onErrorResumeNext().subscribe();
} }
public onAssetSelected(asset: AssetDto) {
this.selected.emit(asset);
}
public onAssetFailed(file: File) {
this.newFiles = this.newFiles.remove(file);
}
public goNext() { public goNext() {
this.state.goNext().onErrorResumeNext().subscribe(); this.state.goNext().onErrorResumeNext().subscribe();
} }
@ -72,10 +61,18 @@ export class AssetsListComponent {
return asset.id; return asset.id;
} }
public select(asset: AssetDto) {
this.selected.emit(asset);
}
public isSelected(asset: AssetDto) { public isSelected(asset: AssetDto) {
return this.selectedIds && this.selectedIds[asset.id]; return this.selectedIds && this.selectedIds[asset.id];
} }
public remove(file: File) {
this.newFiles = this.newFiles.remove(file);
}
public addFiles(files: FileList) { public addFiles(files: FileList) {
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
this.newFiles = this.newFiles.pushFront(files[i]); this.newFiles = this.newFiles.pushFront(files[i]);

4
src/Squidex/app/shared/components/assets-selector.component.html

@ -10,8 +10,8 @@
</ng-container> </ng-container>
<ng-container content> <ng-container content>
<sqx-assets-list assetClass="asset-default" size="4" <sqx-assets-list
(selected)="onAssetSelected($event)" (selected)="selectAsset($event)"
[selectedIds]="selectedAssets" [selectedIds]="selectedAssets"
[state]="state" isDisabled="true"> [state]="state" isDisabled="true">
</sqx-assets-list> </sqx-assets-list>

2
src/Squidex/app/shared/components/assets-selector.component.ts

@ -56,7 +56,7 @@ export class AssetsSelectorComponent implements OnInit {
this.selected.emit(Object.values(this.selectedAssets)); this.selected.emit(Object.values(this.selectedAssets));
} }
public onAssetSelected(asset: AssetDto) { public selectAsset(asset: AssetDto) {
if (this.selectedAssets[asset.id]) { if (this.selectedAssets[asset.id]) {
delete this.selectedAssets[asset.id]; delete this.selectedAssets[asset.id];
} else { } else {

25
src/Squidex/app/shared/components/history.component.ts

@ -6,33 +6,31 @@
*/ */
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { AppContext } from './app-context';
import { import {
allParams, allParams,
AppsState,
formatHistoryMessage, formatHistoryMessage,
HistoryChannelUpdated, HistoryChannelUpdated,
HistoryEventDto, HistoryEventDto,
HistoryService, HistoryService,
MessageBus,
UsersProviderService UsersProviderService
} from '@app/shared/internal'; } from '@app/shared/internal';
@Component({ @Component({
selector: 'sqx-history', selector: 'sqx-history',
styleUrls: ['./history.component.scss'], styleUrls: ['./history.component.scss'],
templateUrl: './history.component.html', templateUrl: './history.component.html'
providers: [
AppContext
]
}) })
export class HistoryComponent { export class HistoryComponent {
public get channel(): string { public get channel(): string {
let channelPath = this.ctx.route.snapshot.data['channel']; let channelPath = this.route.snapshot.data['channel'];
if (channelPath) { if (channelPath) {
const params = allParams(this.ctx.route); const params = allParams(this.route);
for (let key in params) { for (let key in params) {
if (params.hasOwnProperty(key)) { if (params.hasOwnProperty(key)) {
@ -47,12 +45,15 @@ export class HistoryComponent {
} }
public events: Observable<HistoryEventDto[]> = public events: Observable<HistoryEventDto[]> =
Observable.timer(0, 10000).merge(this.ctx.bus.of(HistoryChannelUpdated).delay(1000)) Observable.timer(0, 10000).merge(this.messageBus.of(HistoryChannelUpdated).delay(1000))
.switchMap(app => this.historyService.getHistory(this.ctx.appName, this.channel)); .switchMap(app => this.historyService.getHistory(this.appsState.appName, this.channel));
constructor(public readonly ctx: AppContext, constructor(
private readonly appsState: AppsState,
private readonly users: UsersProviderService, private readonly users: UsersProviderService,
private readonly historyService: HistoryService private readonly historyService: HistoryService,
private readonly messageBus: MessageBus,
private readonly route: ActivatedRoute
) { ) {
} }

4
src/Squidex/app/shared/components/language-selector.component.ts

@ -32,7 +32,7 @@ export class LanguageSelectorComponent implements OnChanges, OnInit {
public selectedLanguage: Language; public selectedLanguage: Language;
@Output() @Output()
public selectedLanguageChanged = new EventEmitter<Language>(); public selectedLanguageChange = new EventEmitter<Language>();
public get isSmallMode(): boolean { public get isSmallMode(): boolean {
return this.languages && this.languages.length > 0 && this.languages.length <= 3; return this.languages && this.languages.length > 0 && this.languages.length <= 3;
@ -62,6 +62,6 @@ export class LanguageSelectorComponent implements OnChanges, OnInit {
public selectLanguage(language: Language) { public selectLanguage(language: Language) {
this.selectedLanguage = language; this.selectedLanguage = language;
this.selectedLanguageChanged.emit(language); this.selectedLanguageChange.emit(language);
} }
} }

1
src/Squidex/app/shared/declarations.ts

@ -5,7 +5,6 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
export * from './components/app-context';
export * from './components/app-form.component'; export * from './components/app-form.component';
export * from './components/asset.component'; export * from './components/asset.component';
export * from './components/assets-list.component'; export * from './components/assets-list.component';

62
src/Squidex/app/shared/guards/content-must-exist.guard.spec.ts

@ -0,0 +1,62 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Router } from '@angular/router';
import { Observable } from 'rxjs';
import { IMock, Mock, Times } from 'typemoq';
import { ContentDto } from './../services/contents.service';
import { ContentsState } from './../state/contents.state';
import { ContentMustExistGuard } from './content-must-exist.guard';
describe('ContentMustExistGuard', () => {
const route: any = {
params: {
contentId: '123'
}
};
let contentsState: IMock<ContentsState>;
let router: IMock<Router>;
let contentGuard: ContentMustExistGuard;
beforeEach(() => {
router = Mock.ofType<Router>();
contentsState = Mock.ofType<ContentsState>();
contentGuard = new ContentMustExistGuard(contentsState.object, router.object);
});
it('should load content and return true when found', () => {
contentsState.setup(x => x.select('123'))
.returns(() => Observable.of(<ContentDto>{}));
let result: boolean;
contentGuard.canActivate(route).subscribe(x => {
result = x;
}).unsubscribe();
expect(result!).toBeTruthy();
contentsState.verify(x => x.select('123'), Times.once());
});
it('should load content and return false when not found', () => {
contentsState.setup(x => x.select('123'))
.returns(() => Observable.of(null));
let result: boolean;
contentGuard.canActivate(route).subscribe(x => {
result = x;
}).unsubscribe();
expect(result!).toBeFalsy();
router.verify(x => x.navigate(['/404']), Times.once());
});
});

38
src/Squidex/app/shared/guards/content-must-exist.guard.ts

@ -0,0 +1,38 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, Router } from '@angular/router';
import { Observable } from 'rxjs';
import { allParams } from '@app/framework';
import { ContentsState } from './../state/contents.state';
@Injectable()
export class ContentMustExistGuard implements CanActivate {
constructor(
private readonly contentsState: ContentsState,
private readonly router: Router
) {
}
public canActivate(route: ActivatedRouteSnapshot): Observable<boolean> {
const contentId = allParams(route)['contentId'];
const result =
this.contentsState.select(contentId)
.do(dto => {
if (!dto) {
this.router.navigate(['/404']);
}
})
.map(u => u !== null);
return result;
}
}

46
src/Squidex/app/shared/guards/resolve-content.guard.ts

@ -1,46 +0,0 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router';
import { Observable } from 'rxjs';
import { allParams } from '@app/framework';
import { ContentDto, ContentsService } from './../services/contents.service';
@Injectable()
export class ResolveContentGuard implements Resolve<ContentDto | null> {
constructor(
private readonly contentsService: ContentsService,
private readonly router: Router
) {
}
public resolve(route: ActivatedRouteSnapshot): Observable<ContentDto | null> {
const params = allParams(route);
const appName = params['appName'];
const contentId = params['contentId'];
const schemaName = params['schemaName'];
const result =
this.contentsService.getContent(appName, schemaName, contentId)
.do(dto => {
if (!dto) {
this.router.navigate(['/404']);
}
})
.catch(error => {
this.router.navigate(['/404']);
return Observable.of(null);
});
return result;
}
}

37
src/Squidex/app/shared/guards/unset-content.guard.spec.ts

@ -0,0 +1,37 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Observable } from 'rxjs';
import { IMock, Mock, Times } from 'typemoq';
import { ContentsState } from './../state/contents.state';
import { UnsetContentGuard } from './unset-content.guard';
describe('UnsetContentGuard', () => {
let contentsState: IMock<ContentsState>;
let contentGuard: UnsetContentGuard;
beforeEach(() => {
contentsState = Mock.ofType<ContentsState>();
contentGuard = new UnsetContentGuard(contentsState.object);
});
it('should unset content', () => {
contentsState.setup(x => x.select(null))
.returns(() => Observable.of(null));
let result: boolean;
contentGuard.canActivate().subscribe(x => {
result = x;
}).unsubscribe();
expect(result!).toBeTruthy();
contentsState.verify(x => x.select(null), Times.once());
});
});

24
src/Squidex/app/shared/guards/unset-content.guard.ts

@ -0,0 +1,24 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Injectable } from '@angular/core';
import { CanActivate } from '@angular/router';
import { Observable } from 'rxjs';
import { ContentsState } from './../state/contents.state';
@Injectable()
export class UnsetContentGuard implements CanActivate {
constructor(
private readonly usersState: ContentsState
) {
}
public canActivate(): Observable<boolean> {
return this.usersState.select(null).map(u => u === null);
}
}

4
src/Squidex/app/shared/internal.ts

@ -6,14 +6,15 @@
*/ */
export * from './guards/app-must-exist.guard'; export * from './guards/app-must-exist.guard';
export * from './guards/content-must-exist.guard';
export * from './guards/load-apps.guard'; export * from './guards/load-apps.guard';
export * from './guards/load-languages.guard'; export * from './guards/load-languages.guard';
export * from './guards/must-be-authenticated.guard'; export * from './guards/must-be-authenticated.guard';
export * from './guards/must-be-not-authenticated.guard'; export * from './guards/must-be-not-authenticated.guard';
export * from './guards/resolve-content.guard';
export * from './guards/schema-must-exist-published.guard'; export * from './guards/schema-must-exist-published.guard';
export * from './guards/schema-must-exist.guard'; export * from './guards/schema-must-exist.guard';
export * from './guards/unset-app.guard'; export * from './guards/unset-app.guard';
export * from './guards/unset-content.guard';
export * from './interceptors/auth.interceptor'; export * from './interceptors/auth.interceptor';
@ -42,6 +43,7 @@ export * from './state/apps.state';
export * from './state/assets.state'; export * from './state/assets.state';
export * from './state/backups.state'; export * from './state/backups.state';
export * from './state/clients.state'; export * from './state/clients.state';
export * from './state/contents.state';
export * from './state/contributors.state'; export * from './state/contributors.state';
export * from './state/languages.state'; export * from './state/languages.state';
export * from './state/patterns.state'; export * from './state/patterns.state';

8
src/Squidex/app/shared/module.ts

@ -34,7 +34,9 @@ import {
BackupsService, BackupsService,
BackupsState, BackupsState,
ClientsState, ClientsState,
ContentMustExistGuard,
ContentsService, ContentsService,
ContentsState,
ContributorsState, ContributorsState,
FileIconPipe, FileIconPipe,
GeolocationEditorComponent, GeolocationEditorComponent,
@ -54,7 +56,6 @@ import {
PatternsState, PatternsState,
PlansService, PlansService,
PlansState, PlansState,
ResolveContentGuard,
RichEditorComponent, RichEditorComponent,
RulesService, RulesService,
RulesState, RulesState,
@ -64,6 +65,7 @@ import {
SchemasState, SchemasState,
UIService, UIService,
UnsetAppGuard, UnsetAppGuard,
UnsetContentGuard,
UsagesService, UsagesService,
UserDtoPicture, UserDtoPicture,
UserIdPicturePipe, UserIdPicturePipe,
@ -146,7 +148,9 @@ export class SqxSharedModule {
BackupsService, BackupsService,
BackupsState, BackupsState,
ClientsState, ClientsState,
ContentMustExistGuard,
ContentsService, ContentsService,
ContentsState,
ContributorsState, ContributorsState,
GraphQlService, GraphQlService,
HelpService, HelpService,
@ -160,7 +164,6 @@ export class SqxSharedModule {
PatternsState, PatternsState,
PlansService, PlansService,
PlansState, PlansState,
ResolveContentGuard,
RulesService, RulesService,
RulesState, RulesState,
SchemaMustExistGuard, SchemaMustExistGuard,
@ -169,6 +172,7 @@ export class SqxSharedModule {
SchemasState, SchemasState,
UIService, UIService,
UnsetAppGuard, UnsetAppGuard,
UnsetContentGuard,
UsagesService, UsagesService,
UsersProviderService, UsersProviderService,
UsersService, UsersService,

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

@ -18,58 +18,6 @@ import {
Version Version
} from './../'; } from './../';
describe('ContentDto', () => {
const creation = DateTime.today();
const creator = 'not-me';
const modified = DateTime.now();
const modifier = 'me';
const dueTime = DateTime.now().addDays(1);
const version = new Version('1');
const newVersion = new Version('2');
it('should update data property and user info when updating', () => {
const content_1 = new ContentDto('1', 'Published', creator, creator, creation, creation, null, null, null, { data: 1 }, version);
const content_2 = content_1.update({ data: 2 }, modifier, newVersion, modified);
expect(content_2.data).toEqual({ data: 2 });
expect(content_2.lastModified).toEqual(modified);
expect(content_2.lastModifiedBy).toEqual(modifier);
expect(content_2.version).toEqual(newVersion);
});
it('should update status property and user info when changing status', () => {
const content_1 = new ContentDto('1', 'Draft', creator, creator, creation, creation, null, null, null, { data: 1 }, version);
const content_2 = content_1.changeStatus('Published', null, modifier, newVersion, modified);
expect(content_2.status).toEqual('Published');
expect(content_2.lastModified).toEqual(modified);
expect(content_2.lastModifiedBy).toEqual(modifier);
expect(content_2.version).toEqual(newVersion);
});
it('should update schedules property and user info when changing status with due time', () => {
const content_1 = new ContentDto('1', 'Draft', creator, creator, creation, creation, null, null, null, { data: 1 }, version);
const content_2 = content_1.changeStatus('Published', dueTime, modifier, newVersion, modified);
expect(content_2.status).toEqual('Draft');
expect(content_2.lastModified).toEqual(modified);
expect(content_2.lastModifiedBy).toEqual(modifier);
expect(content_2.scheduledAt).toEqual(dueTime);
expect(content_2.scheduledBy).toEqual(modifier);
expect(content_2.scheduledTo).toEqual('Published');
expect(content_2.version).toEqual(newVersion);
});
it('should update data property when setting data', () => {
const newData = {};
const content_1 = new ContentDto('1', 'Published', creator, creator, creation, creation, null, null, null, { data: 1 }, version);
const content_2 = content_1.setData(newData);
expect(content_2.data).toBe(newData);
});
});
describe('ContentsService', () => { describe('ContentsService', () => {
const version = new Version('1'); const version = new Version('1');

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

@ -43,60 +43,6 @@ export class ContentDto {
public readonly version: Version public readonly version: Version
) { ) {
} }
public setData(data: any): ContentDto {
return new ContentDto(
this.id,
this.status,
this.createdBy,
this.lastModifiedBy,
this.created,
this.lastModified,
this.scheduledTo,
this.scheduledBy,
this.scheduledAt,
data,
this.version);
}
public changeStatus(status: string, dueTime: DateTime | null, user: string, version: Version, now?: DateTime): ContentDto {
if (dueTime) {
return new ContentDto(
this.id,
this.status,
this.createdBy, user,
this.created, now || DateTime.now(),
status,
user,
dueTime,
this.data,
version);
} else {
return new ContentDto(
this.id,
status,
this.createdBy, user,
this.created, now || DateTime.now(),
null,
null,
null,
this.data,
version);
}
}
public update(data: any, user: string, version: Version, now?: DateTime): ContentDto {
return new ContentDto(
this.id,
this.status,
this.createdBy, user,
this.created, now || DateTime.now(),
this.scheduledTo,
this.scheduledBy,
this.scheduledAt,
data,
version);
}
} }
@Injectable() @Injectable()

13
src/Squidex/app/shared/services/schemas.service.ts

@ -113,17 +113,6 @@ export class SchemaDto {
public readonly version: Version public readonly version: Version
) { ) {
} }
public publish(user: string, version: Version, now?: DateTime): SchemaDto {
return new SchemaDto(
this.id,
this.name,
this.properties,
true,
this.createdBy, user,
this.created, now || DateTime.now(),
version);
}
} }
export class SchemaDetailsDto extends SchemaDto { export class SchemaDetailsDto extends SchemaDto {
@ -146,7 +135,7 @@ export class SchemaDetailsDto extends SchemaDto {
} }
if (this.listFields.length === 0) { if (this.listFields.length === 0) {
this.listFields = [<any>{}]; this.listFields = [<any>{ properties: {} }];
} }
} }
} }

382
src/Squidex/app/shared/state/contents.state.ts

@ -0,0 +1,382 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Injectable } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { Observable } from 'rxjs';
import '@app/framework/utils/rxjs-extensions';
import {
DateTime,
DialogService,
ErrorDto,
Form,
ImmutableArray,
Pager,
State,
Version,
Versioned
} from '@app/framework';
import { AppLanguageDto } from './../services/app-languages.service';
import { AuthService } from './../services/auth.service';
import { fieldInvariant, SchemaDetailsDto, SchemaDto } from './../services/schemas.service';
import { AppsState } from './apps.state';
import { SchemasState } from './schemas.state';
import {
ContentDto,
ContentsService
} from './../services/contents.service';
export class EditContentForm extends Form<FormGroup> {
constructor(
private readonly schema: SchemaDetailsDto,
private readonly languages: ImmutableArray<AppLanguageDto>
) {
super(new FormGroup({}));
for (const field of schema.fields) {
const fieldForm = new FormGroup({});
const defaultValue = field.defaultValue();
if (field.isLocalizable) {
for (let language of this.languages.values) {
fieldForm.setControl(language.iso2Code, new FormControl(defaultValue, field.createValidators(language.isOptional)));
}
} else {
fieldForm.setControl(fieldInvariant, new FormControl(defaultValue, field.createValidators(false)));
}
this.form.setControl(field.name, fieldForm);
}
this.enableContentForm();
}
public submitCompleted(newValue?: any) {
super.submitCompleted(newValue);
this.enableContentForm();
}
public submitFailed(error?: string | ErrorDto) {
super.submitFailed(error);
this.enableContentForm();
}
private enableContentForm() {
if (this.schema.fields.length === 0) {
this.form.enable();
} else {
for (const field of this.schema.fields) {
const fieldForm = this.form.controls[field.name];
if (field.isDisabled) {
fieldForm.disable();
} else {
fieldForm.enable();
}
}
}
}
}
export class PatchContentForm extends Form<FormGroup> {
constructor(
private readonly schema: SchemaDetailsDto,
private readonly language: AppLanguageDto
) {
super(new FormGroup({}));
for (let field of this.schema.listFields) {
if (field.properties && field.properties['inlineEditable']) {
this.form.setControl(field.name, new FormControl(undefined, field.createValidators(this.language.isOptional)));
}
}
}
public submit() {
const result = super.submit();
if (result) {
const request = {};
for (let field of this.schema.listFields) {
if (field.properties['inlineEditable']) {
const value = result[field.name];
if (field.isLocalizable) {
request[field.name] = { [this.language.iso2Code]: value };
} else {
request[field.name] = { iv: value };
}
}
}
return request;
}
return result;
}
}
interface Snapshot {
contents: ImmutableArray<ContentDto>;
contentsPager: Pager;
contentsQuery?: string;
isLoaded?: boolean;
isArchive?: boolean;
selectedContent?: ContentDto | null;
}
export abstract class ContentsStateBase extends State<Snapshot> {
public selectedContent =
this.changes.map(x => x.selectedContent)
.distinctUntilChanged();
public contents =
this.changes.map(x => x.contents)
.distinctUntilChanged();
public contentsPager =
this.changes.map(x => x.contentsPager)
.distinctUntilChanged();
public contentsQuery =
this.changes.map(x => x.contentsQuery)
.distinctUntilChanged();
public isLoaded =
this.changes.map(x => !!x.isLoaded)
.distinctUntilChanged();
public isArchive =
this.changes.map(x => !!x.isArchive)
.distinctUntilChanged();
constructor(
private readonly appsState: AppsState,
private readonly authState: AuthService,
private readonly contentsService: ContentsService,
private readonly dialogs: DialogService
) {
super({ contents: ImmutableArray.of(), contentsPager: new Pager(0) });
}
public select(id: string | null): Observable<ContentDto | null> {
return this.loadContent(id)
.do(content => {
this.next(s => {
const contents = content ? s.contents.replaceBy('id', content) : s.contents;
return { ...s, selectedContent: content, contents };
});
});
}
private loadContent(id: string | null) {
return !id ?
Observable.of(null) :
Observable.of(this.snapshot.contents.find(x => x.id === id))
.switchMap(content => {
if (!content) {
return this.contentsService.getContent(this.appName, this.schemaName, id).catch(() => Observable.of(null));
} else {
return Observable.of(content);
}
});
}
public load(notifyLoad = false): Observable<any> {
return this.contentsService.getContents(this.appName, this.schemaName,
this.snapshot.contentsPager.pageSize,
this.snapshot.contentsPager.skip,
this.snapshot.contentsQuery, undefined,
this.snapshot.isArchive)
.do(dtos => {
if (notifyLoad) {
this.dialogs.notifyInfo('Contents reloaded.');
}
return this.next(s => {
const contents = ImmutableArray.of(dtos.items);
const contentsPager = s.contentsPager.setCount(dtos.total);
let selectedContent = s.selectedContent;
if (selectedContent) {
selectedContent = contents.find(x => x.id === selectedContent!.id) || selectedContent;
}
return { ...s, contents, contentsPager, selectedContent, isLoaded: true };
});
})
.notify(this.dialogs);
}
public create(request: any, publish: boolean, now?: DateTime) {
return this.contentsService.postContent(this.appName, this.schemaName, request, publish)
.do(dto => {
this.dialogs.notifyInfo('Contents created successfully.');
return this.next(s => {
const contents = s.contents.pushFront(dto);
const contentsPager = s.contentsPager.incrementCount();
return { ...s, contents, contentsPager };
});
});
}
public changeStatus(contents: ContentDto[], action: string, dueTime: string | null): Observable<any> {
return Observable.forkJoin(
contents.map(c =>
this.contentsService.changeContentStatus(this.appName, this.schemaName, c.id, action, dueTime, c.version)
.catch(error => Observable.of(error))))
.do(results => {
const error = results.find(x => !!x.error);
if (error) {
this.dialogs.notifyError(error);
}
return Observable.of(error);
})
.switchMap(() => this.load());
}
public delete(contents: ContentDto[]): Observable<any> {
return Observable.forkJoin(
contents.map(c =>
this.contentsService.deleteContent(this.appName, this.schemaName, c.id, c.version)
.catch(error => Observable.of(error))))
.do(results => {
const error = results.find(x => !!x.error);
if (error) {
this.dialogs.notifyError(error);
}
return Observable.of(error);
})
.switchMap(() => this.load());
}
public update(content: ContentDto, request: any, now?: DateTime): Observable<any> {
return this.contentsService.putContent(this.appName, this.schemaName, content.id, request, content.version)
.do(dto => {
this.dialogs.notifyInfo('Contents updated successfully.');
this.replaceContent(updateData(content, request, this.user, dto.version, now));
})
.notify(this.dialogs);
}
public patch(content: ContentDto, request: any, now?: DateTime): Observable<any> {
return this.contentsService.patchContent(this.appName, this.schemaName, content.id, request, content.version)
.do(dto => {
this.dialogs.notifyInfo('Contents updated successfully.');
this.replaceContent(updateData(content, request, this.user, dto.version, now));
})
.notify(this.dialogs);
}
private replaceContent(content: ContentDto) {
return this.next(s => {
const contents = s.contents.replaceBy('id', content);
const selectedContent = s.selectedContent && s.selectedContent.id === content.id ? content : s.selectedContent;
return { ...s, contents, selectedContent };
});
}
public goArchive(isArchive: boolean): Observable<any> {
this.next(s => ({ ...s, contentsPager: new Pager(0), contentsQuery: undefined, isArchive }));
return this.load();
}
public search(query: string): Observable<any> {
this.next(s => ({ ...s, contentsPager: new Pager(0), contentsQuery: query }));
return this.load();
}
public goNext(): Observable<any> {
this.next(s => ({ ...s, contentsPager: s.contentsPager.goNext() }));
return this.load();
}
public goPrev(): Observable<any> {
this.next(s => ({ ...s, contentsPager: s.contentsPager.goPrev() }));
return this.load();
}
public loadVersion(content: ContentDto, version: Version): Observable<Versioned<any>> {
return this.contentsService.getVersionData(this.appName, this.schemaName, content.id, new Version(version.toString()))
.notify(this.dialogs);
}
private get appName() {
return this.appsState.appName;
}
private get user() {
return this.authState.user!.token;
}
protected abstract get schemaName(): string;
}
@Injectable()
export class ContentsState extends ContentsStateBase {
constructor(appsState: AppsState, authState: AuthService, contentsService: ContentsService, dialogs: DialogService,
private readonly schemasState: SchemasState
) {
super(appsState, authState, contentsService, dialogs);
}
protected get schemaName() {
return this.schemasState.schemaName;
}
}
@Injectable()
export class ManualContentsState extends ContentsStateBase {
public schema: SchemaDto;
constructor(
appsState: AppsState, authState: AuthService, contentsService: ContentsService, dialogs: DialogService
) {
super(appsState, authState, contentsService, dialogs);
}
protected get schemaName() {
return this.schema.name;
}
}
const updateData = (content: ContentDto, data: any, user: string, version: Version, now?: DateTime) =>
new ContentDto(
content.id,
content.status,
content.createdBy, user,
content.created, now || DateTime.now(),
content.scheduledTo,
content.scheduledBy,
content.scheduledAt,
data,
version);
Loading…
Cancel
Save