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> {
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 => {
if (notifyLoad) {
this.dialogs.notifyInfo('Users reloaded.');

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

@ -11,11 +11,12 @@ import { DndModule } from 'ng2-dnd';
import {
CanDeactivateGuard,
ContentMustExistGuard,
LoadLanguagesGuard,
ResolveContentGuard,
SchemaMustExistPublishedGuard,
SqxFrameworkModule,
SqxSharedModule
SqxSharedModule,
UnsetContentGuard
} from '@app/shared';
import {
@ -52,15 +53,14 @@ const routes: Routes = [
{
path: 'new',
component: ContentPageComponent,
canActivate: [UnsetContentGuard],
canDeactivate: [CanDeactivateGuard]
},
{
path: ':contentId',
component: ContentPageComponent,
canActivate: [ContentMustExistGuard],
canDeactivate: [CanDeactivateGuard],
resolve: {
content: ResolveContentGuard
},
children: [
{
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">
<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>
<sqx-onboarding-tooltip id="languages" [for]="buttonLanguages" position="topRight" after="120000">
@ -19,21 +23,21 @@
<div [ngSwitch]="field.properties.fieldType">
<div *ngSwitchCase="'Number'">
<div [ngSwitch]="field.properties.editor">
<div [ngSwitch]="field.properties['editor']">
<div *ngSwitchCase="'Input'">
<input class="form-control" type="number" [formControl]="selectedFormControl" [placeholder]="field.displayPlaceholder" />
</div>
<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 *ngSwitchCase="'Dropdown'">
<select class="form-control" [formControl]="selectedFormControl">
<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>
</div>
<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" />
<label class="form-check-label">
{{value}}
@ -43,7 +47,7 @@
</div>
</div>
<div *ngSwitchCase="'String'">
<div [ngSwitch]="field.properties.editor">
<div [ngSwitch]="field.properties['editor']">
<div *ngSwitchCase="'Input'">
<input class="form-control" type="text" [formControl]="selectedFormControl" [placeholder]="field.displayPlaceholder" />
</div>
@ -62,11 +66,11 @@
<div *ngSwitchCase="'Dropdown'">
<select class="form-control" [formControl]="selectedFormControl">
<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>
</div>
<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" />
<label class="form-check-label">
{{value}}
@ -76,7 +80,7 @@
</div>
</div>
<div *ngSwitchCase="'Boolean'">
<div [ngSwitch]="field.properties.editor">
<div [ngSwitch]="field.properties['editor']">
<div *ngSwitchCase="'Toggle'">
<sqx-toggle [formControl]="selectedFormControl"></sqx-toggle>
</div>
@ -88,7 +92,7 @@
</div>
</div>
<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 *ngSwitchCase="'Geolocation'">
<sqx-geolocation-editor [formControl]="selectedFormControl"></sqx-geolocation-editor>
@ -107,12 +111,12 @@
[formControl]="selectedFormControl"
[language]="language"
[languages]="languages"
[schemaId]="field.properties.schemaId">
[schemaId]="field.properties['schemaId']">
</sqx-references-editor>
</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}}
</small>
</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.
*/
import { Component, Input, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
import { AbstractControl, FormGroup } from '@angular/forms';
import {
@ -18,9 +18,10 @@ import {
@Component({
selector: 'sqx-content-field',
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()
public field: FieldDto;
@ -30,6 +31,9 @@ export class ContentFieldComponent implements OnInit {
@Input()
public language: AppLanguageDto;
@Output()
public languageChange = new EventEmitter<AppLanguageDto>();
@Input()
public languages: ImmutableArray<AppLanguageDto>;
@ -38,23 +42,13 @@ export class ContentFieldComponent implements OnInit {
public selectedFormControl: AbstractControl;
public ngOnInit() {
if (!this.language) {
this.language = this.languages.at(0);
}
public ngOnChanges() {
if (this.field.isLocalizable) {
this.selectedFormControl = this.fieldForm.controls[this.language.iso2Code];
this.selectedFormControl['_clearChangeFns']();
} else {
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 { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs';
import {
allParams,
AppContext,
AppsState,
formatHistoryMessage,
HistoryChannelUpdated,
HistoryEventDto,
HistoryService,
UsersProviderService
MessageBus,
UsersProviderService,
Version
} from '@app/shared';
import { ContentVersionSelected } from './../messages';
@ -23,17 +26,14 @@ import { ContentVersionSelected } from './../messages';
@Component({
selector: 'sqx-history',
styleUrls: ['./content-history.component.scss'],
templateUrl: './content-history.component.html',
providers: [
AppContext
]
templateUrl: './content-history.component.html'
})
export class ContentHistoryComponent {
public get channel(): string {
let channelPath = this.ctx.route.snapshot.data['channel'];
let channelPath = this.route.snapshot.data['channel'];
if (channelPath) {
const params = allParams(this.ctx.route);
const params = allParams(this.route);
for (let key in params) {
if (params.hasOwnProperty(key)) {
@ -48,17 +48,20 @@ export class ContentHistoryComponent {
}
public events: Observable<HistoryEventDto[]> =
Observable.timer(0, 10000).merge(this.ctx.bus.of(HistoryChannelUpdated).delay(1000))
.switchMap(app => this.historyService.getHistory(this.ctx.appName, this.channel));
Observable.timer(0, 10000).merge(this.messageBus.of(HistoryChannelUpdated).delay(1000))
.switchMap(app => this.historyService.getHistory(this.appsState.appName, this.channel));
constructor(public readonly ctx: AppContext,
private readonly users: UsersProviderService,
private readonly historyService: HistoryService
constructor(
private readonly appsState: AppsState,
private readonly historyService: HistoryService,
private readonly messageBus: MessageBus,
private readonly route: ActivatedRoute,
private readonly users: UsersProviderService
) {
}
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> {

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()">
<sqx-panel desiredWidth="*" showSidebar="true">
<form [formGroup]="contentForm.form" (ngSubmit)="saveAndPublish()">
<sqx-panel desiredWidth="*" [showSidebar]="content">
<ng-container title>
<a class="btn btn-link" (click)="back()">
<i class="icon-angle-left"></i>
</a>
<ng-container *ngIf="isNewMode">
<ng-container *ngIf="!content">
New Content
</ng-container>
<ng-container *ngIf="!isNewMode && content.status !== 'Archived'">
<ng-container *ngIf="content && content.status !== 'Archived'">
Edit Content
</ng-container>
<ng-container *ngIf="!isNewMode && content.status === 'Archived'">
<ng-container *ngIf="content && content.status === 'Archived'">
Show Content
</ng-container>
</ng-container>
<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">
Save as Draft
</button>
@ -38,24 +38,31 @@
</ng-container>
<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">
<a class="force" (click)="showLatest()">View latest</a>
</div>
Viewing <strong>{{content.lastModifiedBy | sqxUserNameRef:null}}'s</strong> changes of {{content.lastModified | sqxShortDate}}.
Viewing <strong>version {{contentVersion.value</strong>.
</div>
<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>
</ng-container>
<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>
</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.
</sqx-onboarding-tooltip>
</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 { AbstractControl, FormControl, FormGroup } from '@angular/forms';
import { Router } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import { Observable, Subscription } from 'rxjs';
import { ContentVersionSelected } from './../messages';
import {
AppContext,
AppLanguageDto,
AppsState,
CanComponentDeactivate,
ContentDto,
ContentsService,
fieldInvariant,
ContentsState,
DialogService,
EditContentForm,
ImmutableArray,
LanguagesState,
MessageBus,
SchemaDetailsDto,
SchemasState,
Version
@ -29,81 +30,78 @@ import {
@Component({
selector: 'sqx-content-page',
styleUrls: ['./content-page.component.scss'],
templateUrl: './content-page.component.html',
providers: [
AppContext
]
templateUrl: './content-page.component.html'
})
export class ContentPageComponent implements CanComponentDeactivate, OnDestroy, OnInit {
private contentVersionSelectedSubscription: Subscription;
private languagesSubscription: Subscription;
private contentSubscription: Subscription;
private contentVersionSelectedSubscription: Subscription;
private selectedSchemaSubscription: Subscription;
public schema: SchemaDetailsDto;
public content: ContentDto;
public contentOld: ContentDto | null;
public contentFormSubmitted = false;
public contentForm: FormGroup;
public isNewMode = true;
public contentVersion: Version | null;
public contentForm: EditContentForm;
public language: AppLanguageDto;
public languages: ImmutableArray<AppLanguageDto>;
constructor(
public readonly ctx: AppContext,
public readonly languagesState: LanguagesState,
private readonly contentsService: ContentsService,
public readonly appsState: AppsState,
private readonly contentsState: ContentsState,
private readonly dialogs: DialogService,
private readonly languagesState: LanguagesState,
private readonly messageBus: MessageBus,
private readonly route: ActivatedRoute,
private readonly router: Router,
private readonly schemasState: SchemasState
) {
}
public ngOnDestroy() {
this.contentVersionSelectedSubscription.unsubscribe();
this.languagesSubscription.unsubscribe();
this.contentSubscription.unsubscribe();
this.contentVersionSelectedSubscription.unsubscribe();
this.selectedSchemaSubscription.unsubscribe();
}
public ngOnInit() {
this.contentVersionSelectedSubscription =
this.ctx.bus.of(ContentVersionSelected)
.subscribe(message => {
this.loadVersion(message.version);
});
this.languagesSubscription =
this.languagesState.languages
.subscribe(languages => {
this.languages = languages.map(x => x.language);
this.language = this.languages.at(0);
});
this.selectedSchemaSubscription =
this.schemasState.selectedSchema
this.schemasState.selectedSchema.filter(s => !!s)
.subscribe(schema => {
this.setupContentForm(schema!);
this.schema = schema!;
this.contentForm = new EditContentForm(this.schema, this.languages);
});
this.ctx.route.data.map(d => d.content)
.subscribe((content: ContentDto) => {
this.reloadContentForm(content);
this.contentSubscription =
this.contentsState.selectedContent.filter(c => !!c)
.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> {
if (!this.contentForm.dirty || this.isNewMode) {
if (!this.contentForm.form.dirty || !this.content) {
return Observable.of(true);
} else {
return this.ctx.confirmUnsavedChanges();
}
}
public showLatest() {
if (this.contentOld) {
this.content = this.contentOld;
this.contentOld = null;
this.reloadContentForm(this.content);
return this.dialogs.confirmUnsavedChanges();
}
}
@ -116,153 +114,59 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy,
}
private saveContent(publish: boolean) {
this.contentFormSubmitted = true;
if (this.contentForm.valid) {
this.disableContentForm();
const requestDto = this.contentForm.value;
const value = this.contentForm.submit();
if (this.isNewMode) {
this.contentsService.postContent(this.ctx.appName, this.schema.name, requestDto, publish)
if (value) {
if (!this.content) {
this.contentsState.create(value, publish)
.subscribe(dto => {
this.content = dto;
this.ctx.notifyInfo('Content created successfully.');
this.back();
}, error => {
this.ctx.notifyError(error);
this.enableContentForm();
this.contentForm.submitFailed(error);
});
} else {
this.contentsService.putContent(this.ctx.appName, this.schema.name, this.content.id, requestDto, this.content.version)
this.contentsState.create(value, publish)
.subscribe(dto => {
const content = this.content.update(dto.payload, this.ctx.userToken, dto.version);
this.ctx.notifyInfo('Content saved successfully.');
this.enableContentForm();
this.reloadContentForm(content);
this.contentForm.submitCompleted();
}, error => {
this.ctx.notifyError(error);
this.enableContentForm();
this.contentForm.submitFailed(error);
});
}
} else {
this.ctx.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);
});
this.dialogs.notifyError('Content element not valid, please check the field with the red bar on the left in all languages (if localizable).');
}
}
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() {
this.contentForm.disable();
private loadContent(data: any) {
this.contentForm.load(data);
}
private enableContentForm() {
this.contentForm.markAsPristine();
if (this.schema.fields.length === 0) {
this.contentForm.enable();
} else {
for (const field of this.schema.fields) {
const fieldForm = <FormGroup>this.contentForm.controls[field.name];
if (field.isDisabled) {
fieldForm.disable();
private loadVersion(version: Version) {
if (this.content) {
this.contentsState.loadVersion(this.content, version)
.subscribe(dto => {
if (this.content.version.value !== version.toString()) {
this.contentVersion = version;
} 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) {
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]);
}
this.loadContent(dto);
});
}
if (this.content.status === 'Archived') {
this.contentForm.disable();
}
} else {
for (const field of this.schema.fields) {
const defaultValue = field.defaultValue();
if (defaultValue) {
const fieldForm = <FormGroup>this.contentForm.controls[field.name];
public showLatest() {
if (this.contentVersion) {
this.contentVersion = null;
if (field.isLocalizable) {
for (let language of this.languages.values) {
fieldForm.controls[language.iso2Code].setValue(defaultValue);
}
} else {
fieldForm.controls[fieldInvariant].setValue(defaultValue);
}
}
}
this.loadContent(this.content.data);
}
}
}

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">
<ng-container title>
<ng-container *ngIf="isArchive; else noArchive">
<ng-container *ngIf="contentsState.isArchive | async; else noArchive">
Archive
</ng-container>
@ -12,26 +12,34 @@
</ng-container>
<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
</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">
<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>
<button class="btn btn-success" #newButton routerLink="new" title="New Content (CTRL + SHIFT + G)">
<i class="icon-plus"></i> New
</button>
<sqx-shortcut keys="ctrl+shift+g" (trigger)="newButton.click()"></sqx-shortcut>
</ng-container>
<ng-container content>
<div class="grid-header">
<table class="table table-items table-fixed" *ngIf="contentItems">
<table class="table table-items table-fixed">
<thead>
<tr>
<th class="cell-select" *ngIf="!isReadOnly">
@ -65,11 +73,11 @@
Unpublish
</button>
<button class="btn btn-secondary" (click)="archiveSelected()" *ngIf="!isArchive">
<button class="btn btn-secondary" (click)="archiveSelected()" *ngIf="(contentsState.isArchive | async) === false">
Archive
</button>
<button class="btn btn-secondary" (click)="restoreSelected()" *ngIf="isArchive">
<button class="btn btn-secondary" (click)="restoreSelected()" *ngIf="contentsState.isArchive | async">
Restore
</button>
@ -83,20 +91,20 @@
<div class="grid-content">
<div sqxIgnoreScrollbar>
<table class="table table-items table-fixed" *ngIf="contentItems">
<table class="table table-items table-fixed">
<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"
[language]="language"
[schema]="schema"
[selected]="isItemSelected(content)"
(selectedChange)="selectItem(content, $event)"
(unpublishing)="unpublishContent(content)"
(publishing)="publishContent(content)"
(archiving)="archiveContent(content)"
(restoring)="restoreContent(content)"
(deleting)="deleteContent(content)"
(saved)="onContentSaved(content, $event)"></tr>
(unpublishing)="unpublish(content)"
(publishing)="publish(content)"
(archiving)="archive(content)"
(restoring)="restore(content)"
(deleting)="delete(content)">
</tr>
<tr class="spacer"></tr>
</ng-template>
</tbody>
@ -105,7 +113,7 @@
</div>
<div class="grid-footer">
<sqx-pager [pager]="contentsPager"></sqx-pager>
<sqx-pager [pager]="contentsState.contentsPager | async"></sqx-pager>
</div>
</ng-container>
</sqx-panel>
@ -136,7 +144,7 @@
<ng-container footer>
<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>
</sqx-modal-dialog>
</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 { Observable, Subscription } from 'rxjs';
import { Subscription } from 'rxjs';
import {
AppContext,
AppLanguageDto,
AppsState,
ContentDto,
ContentsService,
DateTime,
ContentsState,
ImmutableArray,
LanguagesState,
ModalView,
Pager,
SchemaDetailsDto,
SchemasState,
Versioned
SchemasState
} from '@app/shared';
@Component({
selector: 'sqx-contents-page',
styleUrls: ['./contents-page.component.scss'],
templateUrl: './contents-page.component.html',
providers: [
AppContext
]
templateUrl: './contents-page.component.html'
})
export class ContentsPageComponent implements OnDestroy, OnInit {
private selectedSchemaSubscription: Subscription;
private contentsSubscription: Subscription;
private languagesSubscription: Subscription;
private selectedSchemaSubscription: Subscription;
public schema: SchemaDetailsDto;
public searchModal = new ModalView();
public contentItems: ImmutableArray<ContentDto>;
public contentsQuery = '';
public contentsPager = new Pager(0);
public dueTimeDialog = new ModalView();
public dueTime: string | null = '';
public dueTimeFunction: Function | null;
@ -57,19 +48,19 @@ export class ContentsPageComponent implements OnDestroy, OnInit {
public language: AppLanguageDto;
public languages: ImmutableArray<AppLanguageDto>;
public languageParameter: string;
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 contentsService: ContentsService,
private readonly schemasState: SchemasState
) {
}
public ngOnDestroy() {
this.contentsSubscription.unsubscribe();
this.languagesSubscription.unsubscribe();
this.selectedSchemaSubscription.unsubscribe();
}
@ -78,10 +69,17 @@ export class ContentsPageComponent implements OnDestroy, OnInit {
this.selectedSchemaSubscription =
this.schemasState.selectedSchema
.subscribe(schema => {
this.resetSelection();
this.schema = schema!;
this.resetContents();
this.load();
this.contentsState.load().onErrorResumeNext().subscribe();
});
this.contentsSubscription =
this.contentsState.contents
.subscribe(() => {
this.updateSelectionSummary();
});
this.languagesSubscription =
@ -90,180 +88,105 @@ export class ContentsPageComponent implements OnDestroy, OnInit {
this.languages = languages.map(x => x.language);
this.language = this.languages.at(0);
});
this.contentsState.load().onErrorResumeNext().subscribe();
}
public publishContent(content: ContentDto) {
this.changeContentItems([content], 'Publish', 'Published', false);
public reload() {
this.contentsState.load(true).onErrorResumeNext().subscribe();
}
public publishSelected(scheduled: boolean) {
const contents = this.contentItems.filter(c => c.status !== 'Published' && this.selectedItems[c.id]).values;
public publish(content: ContentDto) {
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) {
this.changeContentItems([content], 'Unpublish', 'Draft', false);
public unpublish(content: ContentDto) {
this.changeContentItems([content], 'Unpublish', false);
}
public unpublishSelected(scheduled: boolean) {
const contents = this.contentItems.filter(c => c.status === 'Published' && this.selectedItems[c.id]).values;
this.changeContentItems(contents, 'Unpublish', 'Draft', false);
this.changeContentItems(this.s(c => c.status === 'Published'), 'Unpublish', false);
}
public archiveContent(content: ContentDto) {
this.changeContentItems([content], 'Archive', 'Archived', true);
public archive(content: ContentDto) {
this.changeContentItems([content], 'Archive', true);
}
public archiveSelected(scheduled: boolean) {
const contents = this.contentItems.filter(c => this.selectedItems[c.id]).values;
this.changeContentItems(contents, 'Archive', 'Archived', true);
this.changeContentItems(this.s(), 'Archive', true);
}
public restoreContent(content: ContentDto) {
this.changeContentItems([content], 'Restore', 'Draft', true);
public restore(content: ContentDto) {
this.changeContentItems([content], 'Restore', true);
}
public restoreSelected(scheduled: boolean) {
const contents = this.contentItems.filter(c => this.selectedItems[c.id]).values;
this.changeContentItems(contents, 'Restore', 'Draft', true);
this.changeContentItems(this.s(), 'Restore', true);
}
private changeContentItems(contents: ContentDto[], action: string, status: string, reload: boolean) {
private changeContentItems(contents: ContentDto[], action: string, reload: boolean) {
if (contents.length === 0) {
return;
}
this.dueTimeFunction = () => {
if (this.dueTime) {
reload = false;
}
Observable.forkJoin(
contents
.map(c => this.changeContentItem(c, action, status, this.dueTime, reload)))
.finally(() => {
if (reload) {
this.load();
} else {
this.updateSelectionSummary();
}
})
.subscribe();
this.resetSelection();
this.contentsState.changeStatus(contents, action, this.dueTime).onErrorResumeNext().subscribe();
};
this.dueTimeAction = action;
this.dueTimeDialog.show();
}
private changeContentItem(content: ContentDto, action: string, status: string, dueTime: string | null, reload: boolean): Observable<any> {
return this.contentsService.changeContentStatus(this.ctx.appName, this.schema.name, content.id, action, dueTime, content.version)
.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);
public deleteSelected() {
this.resetSelection();
this.contentItems = this.contentItems.replaceBy('id', content);
}
});
this.contentsState.delete(this.s()).onErrorResumeNext().subscribe();
}
public deleteSelected(content: ContentDto) {
Observable.forkJoin(
this.contentItems.values.filter(c => this.selectedItems[c.id])
.map(c => this.deleteContentItem(c)))
.finally(() => {
this.load();
})
.subscribe();
}
public delete(content: ContentDto) {
this.resetSelection();
public deleteContent(content: ContentDto) {
this.deleteContentItem(content)
.finally(() => {
this.load();
})
.subscribe();
this.contentsState.delete([content]).onErrorResumeNext().subscribe();
}
public deleteContentItem(content: ContentDto): Observable<any> {
return this.contentsService.deleteContent(this.ctx.appName, this.schema.name, content.id, content.version)
.catch(error => {
this.ctx.notifyError(error);
public goArchive(isArchive: boolean) {
this.resetSelection();
return Observable.throw(error);
});
this.contentsState.goArchive(isArchive).onErrorResumeNext().subscribe();
}
public onContentSaved(content: ContentDto, update: Versioned<any>) {
content = content.update(update.payload, this.ctx.userToken, update.version);
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);
public goPrev() {
this.resetSelection();
this.load();
this.contentsState.goPrev().onErrorResumeNext().subscribe();
}
public goNext() {
this.contentsPager = this.contentsPager.goNext();
this.resetSelection();
this.load();
this.contentsState.goNext().onErrorResumeNext().subscribe();
}
public goPrev() {
this.contentsPager = this.contentsPager.goPrev();
public search(query: string) {
this.resetSelection();
this.load();
this.contentsState.search(query).onErrorResumeNext().subscribe();
}
public isItemSelected(content: ContentDto): boolean {
return !!this.selectedItems[content.id];
}
public selectLanguage(language: AppLanguageDto) {
this.language = language;
}
public selectItem(content: ContentDto, isSelected: boolean) {
this.selectedItems[content.id] = isSelected;
@ -274,65 +197,66 @@ export class ContentsPageComponent implements OnDestroy, OnInit {
this.selectedItems = {};
if (isSelected) {
for (let c of this.contentItems.values) {
this.selectedItems[c.id] = true;
for (let content of this.contentsState.snapshot.contents.values) {
this.selectedItems[content.id] = true;
}
}
this.updateSelectionSummary();
}
private updateSelectionSummary() {
this.isAllSelected = this.contentItems.length > 0;
this.selectionCount = 0;
this.canPublish = true;
this.canUnpublish = true;
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 confirmStatusChange() {
this.dueTimeFunction!();
this.dueTimeFunction = null;
this.dueTimeMode = 'Immediately';
this.dueTimeDialog.hide();
this.dueTime = null;
}
public selectLanguage(language: AppLanguageDto) {
this.language = language;
public cancelStatusChange() {
this.dueTimeMode = 'Immediately';
this.dueTimeDialog.hide();
this.dueTimeFunction = null;
this.dueTime = null;
}
public trackByContent(content: ContentDto): string {
return content.id;
}
private resetContents() {
this.contentItems = ImmutableArray.empty<ContentDto>();
this.contentsQuery = '';
this.contentsPager = new Pager(0);
private s(predicate?: (content: ContentDto) => boolean) {
return this.contentsState.snapshot.contents.values.filter(c => this.selectedItems[c.id] && (!predicate || predicate(c)));
}
private resetSelection() {
this.selectedItems = {};
this.updateSelectionSummary();
}
public confirmStatusChange() {
this.dueTimeFunction!();
private updateSelectionSummary() {
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() {
this.dueTimeMode = 'Immediately';
this.dueTimeDialog.hide();
this.dueTimeFunction = null;
this.dueTime = null;
if (content.status === 'Published') {
this.canPublish = false;
}
} else {
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 odataSearch = '';
if (this.query) {
const parts = this.query.split('&');
if (parts.length === 1 && parts[0][0] !== '$') {
@ -90,6 +91,7 @@ export class SearchFormComponent implements OnChanges {
}
}
}
}
this.searchForm.setValue({
odataFilter,

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

@ -5,9 +5,11 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Version } from '@app/shared';
export class ContentVersionSelected {
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[]) {
this.oldAssets = ImmutableArray.empty<AssetDto>();
if (Types.isArrayOfString(value) && value.length > 0) {
if (Types.isArrayOfString(value) && !Types.isEquals(value, this.oldAssets.map(x => x.id).values)) {
const assetIds: string[] = value;
this.assetsService.getAssets(this.appsState.appName, 0, 0, undefined, value)
.subscribe(dtos => {
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 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 *ngSwitchCase="'Number'">
<div [ngSwitch]="field.properties.editor">
<div [ngSwitch]="field.properties['editor']">
<div *ngSwitchCase="'Input'">
<input class="form-control" type="number" [formControlName]="field.name" [placeholder]="field.displayPlaceholder" />
</div>
<div *ngSwitchCase="'Dropdown'">
<select class="form-control" [formControlName]="field.name">
<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>
</div>
</div>
</div>
<div *ngSwitchCase="'String'">
<div [ngSwitch]="field.properties.editor">
<div [ngSwitch]="field.properties['editor']">
<div *ngSwitchCase="'Input'">
<input class="form-control" type="text" [formControlName]="field.name" [placeholder]="field.displayPlaceholder" />
</div>
@ -32,13 +32,13 @@
<div *ngSwitchCase="'Dropdown'">
<select class="form-control" [formControlName]="field.name">
<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>
</div>
</div>
</div>
<div *ngSwitchCase="'Boolean'">
<div [ngSwitch]="field.properties.editor">
<div [ngSwitch]="field.properties['editor']">
<div *ngSwitchCase="'Toggle'">
<sqx-toggle [formControlName]="field.name"></sqx-toggle>
</div>
@ -51,7 +51,7 @@
</div>
</div>
</div>
<div *ngIf="!field.properties.inlineEditable || isReadOnly" class="truncate">
<div *ngIf="!field.properties['inlineEditable'] || isReadOnly" class="truncate">
{{values[i]}}
</div>
</td>
@ -75,22 +75,22 @@
<small class="item-modified">{{content.lastModified | sqxFromNow}}</small>
</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()">
<i class="icon-checkmark"></i>
</button>
</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()">
<i class="icon-close"></i>
</button>
</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" />
</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">
<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>

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

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

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

@ -9,17 +9,21 @@
<i class="icon-reset"></i> Refresh
</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">
<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>
</div>
</ng-container>
<ng-container content>
<div class="grid-header">
<table class="table table-items table-fixed" *ngIf="contentItems">
<table class="table table-items table-fixed">
<thead>
<tr>
<th class="cell-select">
@ -41,12 +45,12 @@
<div class="grid-content">
<div sqxIgnoreScrollbar>
<table class="table table-items table-fixed" *ngIf="contentItems">
<table class="table table-items table-fixed">
<tbody>
<ng-template ngFor let-content [ngForOf]="contentItems" [ngForTrackBy]="trackByContent">
<ng-template ngFor let-content [ngForOf]="contentsState.contents | async" [ngForTrackBy]="trackByContent">
<tr [sqxContent]="content"
[selected]="isItemSelected(content)"
(selectedChange)="onContentSelected(content)"
(selectedChange)="selectContent(content)"
[language]="language"
[schema]="schema"
isReadOnly="true"></tr>
@ -58,7 +62,7 @@
</div>
<div class="grid-footer">
<sqx-pager [pager]="contentsPager"></sqx-pager>
<sqx-pager [pager]="contentsState.contentsPager | async"></sqx-pager>
</div>
</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 {
AppsState,
ContentDto,
ContentsService,
DialogService,
ImmutableArray,
LanguageDto,
ManualContentsState,
ModalView,
Pager,
SchemaDetailsDto
} from '@app/shared';
@Component({
selector: 'sqx-contents-selector',
styleUrls: ['./contents-selector.component.scss'],
templateUrl: './contents-selector.component.html'
templateUrl: './contents-selector.component.html',
providers: [
ManualContentsState
]
})
export class ContentsSelectorComponent implements OnInit {
@Input()
@ -39,76 +38,42 @@ export class ContentsSelectorComponent implements OnInit {
public searchModal = new ModalView();
public contentItems: ImmutableArray<ContentDto>;
public contentsQuery = '';
public contentsPager = new Pager(0);
public selectedItems: { [id: string]: ContentDto; } = {};
public selectionCount = 0;
public isAllSelected = false;
constructor(
private readonly appsState: AppsState,
private readonly contentsService: ContentsService,
private readonly dialogs: DialogService
public readonly contentsState: ManualContentsState
) {
}
public ngOnInit() {
this.load();
}
this.contentsState.schema = this.schema;
public reload() {
this.load(true);
this.contentsState.load().onErrorResumeNext().subscribe();
}
private load(notifyLod = false) {
this.contentsService.getContents(this.appsState.appName, this.schema.name, this.contentsPager.pageSize, this.contentsPager.skip, this.contentsQuery, undefined, false)
.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 reload() {
this.contentsState.load(true).onErrorResumeNext().subscribe();
}
public search(query: string) {
this.contentsQuery = query;
this.contentsPager = new Pager(0);
this.load();
this.contentsState.search(query).onErrorResumeNext().subscribe();
}
public goNext() {
this.contentsPager = this.contentsPager.goNext();
this.load();
this.contentsState.goNext().onErrorResumeNext().subscribe();
}
public goPrev() {
this.contentsPager = this.contentsPager.goPrev();
this.load();
this.contentsState.goPrev().onErrorResumeNext().subscribe();
}
public isItemSelected(content: ContentDto) {
return this.selectedItems[content.id];
}
public selectLanguage(language: LanguageDto) {
this.language = language;
}
public complete() {
this.selected.emit([]);
}
@ -117,11 +82,15 @@ export class ContentsSelectorComponent implements OnInit {
this.selected.emit(Object.values(this.selectedItems));
}
public selectLanguage(language: LanguageDto) {
this.language = language;
}
public selectAll(isSelected: boolean) {
this.selectedItems = {};
if (isSelected) {
for (let content of this.contentItems.values) {
for (let content of this.contentsState.snapshot.contents.values) {
this.selectedItems[content.id] = content;
}
}
@ -129,7 +98,7 @@ export class ContentsSelectorComponent implements OnInit {
this.updateSelectionSummary();
}
public onContentSelected(content: ContentDto) {
public selectContent(content: ContentDto) {
if (this.selectedItems[content.id]) {
delete this.selectedItems[content.id];
} else {
@ -142,7 +111,7 @@ export class ContentsSelectorComponent implements OnInit {
private updateSelectionSummary() {
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 {

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

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

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

@ -5,17 +5,6 @@
pointer-events: none;
}
.references {
&-container {
& {
background: $color-background;
overflow-x: hidden;
overflow-y: scroll;
padding: 1rem;
}
}
}
.invalid {
padding: 2rem;
font-size: 1.2rem;
@ -24,6 +13,15 @@
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 {
& {
@include transition(border-color .4s ease);
@ -43,12 +41,6 @@
}
.table {
& {
margin-bottom: -.25rem;
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() {
if (this.schemaId === MathHelper.EMPTY_GUID) {
this.isInvalidSchema = true;
return;
}
@ -77,14 +78,18 @@ export class ReferencesEditorComponent implements ControlValueAccessor, OnInit {
}
public writeValue(value: string[]) {
this.contentItems = ImmutableArray.empty<ContentDto>();
if (Types.isArrayOfString(value) && value.length > 0) {
if (Types.isArrayOfString(value) && !Types.isEquals(value, this.contentItems.map(x => x.id).values)) {
const contentIds: string[] = value;
this.contentsService.getContents(this.appsState.appName, this.schemaId, 10000, 0, undefined, contentIds)
.subscribe(dtos => {
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;
}
public onContentsSelected(contents: ContentDto[]) {
public select(contents: ContentDto[]) {
for (let content of contents) {
this.contentItems = this.contentItems.push(content);
}
@ -121,7 +126,7 @@ export class ReferencesEditorComponent implements ControlValueAccessor, OnInit {
this.hideModal();
}
public onContentRemoving(content: ContentDto) {
public remove(content: ContentDto) {
if (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) {
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">
<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 {
AppContext,
AppsState,
DialogService,
ImmutableArray,
Pager,
RuleEventDto,
@ -18,10 +19,7 @@ import {
@Component({
selector: 'sqx-rule-events-page',
styleUrls: ['./rule-events-page.component.scss'],
templateUrl: './rule-events-page.component.html',
providers: [
AppContext
]
templateUrl: './rule-events-page.component.html'
})
export class RuleEventsPageComponent implements OnInit {
public eventsItems = ImmutableArray.empty<RuleEventDto>();
@ -29,7 +27,9 @@ export class RuleEventsPageComponent implements OnInit {
public selectedEventId: string | null = null;
constructor(public readonly ctx: AppContext,
constructor(
public readonly appsState: AppsState,
private readonly dialogs: DialogService,
private readonly rulesService: RulesService
) {
}
@ -39,25 +39,25 @@ export class RuleEventsPageComponent implements OnInit {
}
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 => {
this.eventsItems = ImmutableArray.of(dtos.items);
this.eventsPager = this.eventsPager.setCount(dtos.total);
if (notifyLoad) {
this.ctx.notifyInfo('Events reloaded.');
this.dialogs.notifyInfo('Events reloaded.');
}
}, error => {
this.ctx.notifyError(error);
this.dialogs.notifyError(error);
});
}
public enqueueEvent(event: RuleEventDto) {
this.rulesService.enqueueEvent(this.ctx.appName, event.id)
this.rulesService.enqueueEvent(this.appsState.appName, event.id)
.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 => {
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;
}
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 class="row">
<sqx-asset class="{{assetClass}}" *ngFor="let file of newFiles" [initFile]="file"
(failed)="onAssetFailed(file)"
(loaded)="onAssetLoaded(file, $event)">
<sqx-asset *ngFor="let file of newFiles" [initFile]="file"
(failed)="remove(file)"
(loaded)="add(file, $event)">
</sqx-asset>
<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"
[isSelectable]="selectedIds"
[isSelected]="isSelected(asset)"
(selected)="onAssetSelected($event)"
(deleting)="onAssetDeleting($event)">
(selected)="select($event)"
(deleting)="delete($event)">
</sqx-asset>
</ng-container>
</div>

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

@ -32,13 +32,10 @@ export class AssetsListComponent {
@Input()
public selectedIds: object;
@Input()
public assetClass = '';
@Output()
public selected = new EventEmitter<AssetDto>();
public onAssetLoaded(file: File, asset: AssetDto) {
public add(file: File, asset: AssetDto) {
this.newFiles = this.newFiles.remove(file);
this.state.add(asset);
@ -48,18 +45,10 @@ export class AssetsListComponent {
this.state.load().onErrorResumeNext().subscribe();
}
public onAssetDeleting(asset: AssetDto) {
public delete(asset: AssetDto) {
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() {
this.state.goNext().onErrorResumeNext().subscribe();
}
@ -72,10 +61,18 @@ export class AssetsListComponent {
return asset.id;
}
public select(asset: AssetDto) {
this.selected.emit(asset);
}
public isSelected(asset: AssetDto) {
return this.selectedIds && this.selectedIds[asset.id];
}
public remove(file: File) {
this.newFiles = this.newFiles.remove(file);
}
public addFiles(files: FileList) {
for (let i = 0; i < files.length; 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 content>
<sqx-assets-list assetClass="asset-default" size="4"
(selected)="onAssetSelected($event)"
<sqx-assets-list
(selected)="selectAsset($event)"
[selectedIds]="selectedAssets"
[state]="state" isDisabled="true">
</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));
}
public onAssetSelected(asset: AssetDto) {
public selectAsset(asset: AssetDto) {
if (this.selectedAssets[asset.id]) {
delete this.selectedAssets[asset.id];
} else {

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

@ -6,33 +6,31 @@
*/
import { Component } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs';
import { AppContext } from './app-context';
import {
allParams,
AppsState,
formatHistoryMessage,
HistoryChannelUpdated,
HistoryEventDto,
HistoryService,
MessageBus,
UsersProviderService
} from '@app/shared/internal';
@Component({
selector: 'sqx-history',
styleUrls: ['./history.component.scss'],
templateUrl: './history.component.html',
providers: [
AppContext
]
templateUrl: './history.component.html'
})
export class HistoryComponent {
public get channel(): string {
let channelPath = this.ctx.route.snapshot.data['channel'];
let channelPath = this.route.snapshot.data['channel'];
if (channelPath) {
const params = allParams(this.ctx.route);
const params = allParams(this.route);
for (let key in params) {
if (params.hasOwnProperty(key)) {
@ -47,12 +45,15 @@ export class HistoryComponent {
}
public events: Observable<HistoryEventDto[]> =
Observable.timer(0, 10000).merge(this.ctx.bus.of(HistoryChannelUpdated).delay(1000))
.switchMap(app => this.historyService.getHistory(this.ctx.appName, this.channel));
Observable.timer(0, 10000).merge(this.messageBus.of(HistoryChannelUpdated).delay(1000))
.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 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;
@Output()
public selectedLanguageChanged = new EventEmitter<Language>();
public selectedLanguageChange = new EventEmitter<Language>();
public get isSmallMode(): boolean {
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) {
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.
*/
export * from './components/app-context';
export * from './components/app-form.component';
export * from './components/asset.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/content-must-exist.guard';
export * from './guards/load-apps.guard';
export * from './guards/load-languages.guard';
export * from './guards/must-be-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.guard';
export * from './guards/unset-app.guard';
export * from './guards/unset-content.guard';
export * from './interceptors/auth.interceptor';
@ -42,6 +43,7 @@ export * from './state/apps.state';
export * from './state/assets.state';
export * from './state/backups.state';
export * from './state/clients.state';
export * from './state/contents.state';
export * from './state/contributors.state';
export * from './state/languages.state';
export * from './state/patterns.state';

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

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

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

@ -18,58 +18,6 @@ import {
Version
} 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', () => {
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 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()

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

@ -113,17 +113,6 @@ export class SchemaDto {
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 {
@ -146,7 +135,7 @@ export class SchemaDetailsDto extends SchemaDto {
}
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